Git, ramas y Pull Requests — cómo trabajan los equipos
Desde git init hasta push y pull, mantener .venv y .env fuera con .gitignore, ramas de funcionalidad pequeñas, mensajes de commit limpios y Pull Requests — además de una herramienta en Python que lee git log para revisar commits.
¿Cómo quieres aprender?
El problema que vamos a resolver
Un ingeniero de IA ha pasado una semana en un proyecto de clasificación de tickets: cambiando el prompt, ampliando la lista de palabras clave, reescribiendo una función. El viernes los resultados son peores que el lunes. ¿Qué cambio tiene la culpa? La carpeta contiene triage.py, triage_v2.py, triage_final.py y triage_final_REAL.py, y nadie sabe qué cambió en cada uno.
Mientras tanto, un compañero que edita el mismo archivo en una carpeta compartida ha borrado su trabajo. Y hace un mes alguien pegó una clave de API directamente en el código para una prueba rápida, y a través de esa carpeta ya ha llegado a diez personas más.
Los tres problemas tienen una sola respuesta, y casi todos los equipos de software del mundo la usan: guardar el historial con Git, hacer cada trabajo en su propia rama y, antes de que algo llegue al código principal, pedir a otra persona que lo revise en un Pull Request. Este capítulo enseña todo ese ciclo, y termina escribiendo una herramienta en Python que automatiza una de las tareas del revisor.
Al terminar este capítulo podrás
- Guardar y compartir el historial de un proyecto con
git init,add,commit,pushypull - Mantener
.venvy.envfuera del repositorio con.gitignore, y saber qué hacer cuando uno de ellos se sube por error - Trabajar en ramas de funcionalidad pequeñas y de vida corta, y abrir un Pull Request limpio
- Escribir mensajes de commit como los leen los revisores
- Escribir una herramienta en Python que lee
git logconsubprocessy revisa los mensajes de commit
Requisitos previos: El recorrido de ingeniero de IA, y entornos de desarrollo con uv — sobre todo uv init y uv run.
Configuración inicial
Git registra quién hizo cada commit. Así que la primera vez te presentas una sola vez, y conviene decirle a Git que nombre la rama principal de los repositorios nuevos main, porque es lo que usan hoy la mayoría de los equipos:
git config --global user.name "Nadia Karim"
git config --global user.email "[email protected]"
git config --global init.defaultBranch mainUsa tu propio nombre y el correo con el que está registrada tu cuenta del servicio de alojamiento de código. Sin la última línea, las versiones antiguas de Git llaman master a la rama principal — lo cual funciona, pero no coincidirá con el resto del equipo.
Tres lugares: la carpeta, el staging y el historial
En el capítulo anterior, uv init creó por sí solo un repositorio Git. El proyecto de ejemplo de este capítulo empieza igual:
uv init ticket-triage --python 3.12
cd ticket-triageGit piensa en tres lugares, y cada comando mueve algo de uno a otro:
- El directorio de trabajo — tu carpeta, donde editas los archivos
- El área de staging — la lista de lo que entrará en el próximo commit;
git addlo pone aquí - El historial — la cadena de commits;
git commitconvierte el área de staging en un commit permanente
¿Por qué un paso de staging aparte? Porque en mitad de una tarea puedes haber cambiado cinco archivos, dos de los cuales arreglan un bug y tres añaden una funcionalidad. El staging te permite separarlos en dos commits distintos y con sentido — algo muy valioso para un revisor.
El comando para ver el estado actual es git status, o en forma corta git status --short.
.gitignore: lo que nunca entra en el repositorio
Los proyectos de IA casi siempre tienen un archivo .env con claves de API. Supongamos que el proyecto recibe un archivo como este (sk-... aquí es solo un marcador de posición, no una clave real):
LLM_API_KEY=sk-...El .gitignore que crea uv init contiene .venv pero no .env. Así que lo primero es añadirlo al final:
# Secrets
.envAhora ejecuta uv run main.py una vez (así se crean .venv y uv.lock) y revisa el estado:
git status --short
?? .gitignore
?? .python-version
?? README.md
?? main.py
?? pyproject.toml
?? uv.lock?? significa que Git ve el archivo pero todavía no lo rastrea. Lo importante es lo que no aparece en la lista: ni .venv ni .env. Los dos están en la carpeta, pero Git finge que no los ve. (.venv tiene en realidad dos capas de protección: cuando uv crea el entorno, también deja dentro un .gitignore diminuto que contiene solo *.) Y uv.lock sí está — debe ir en el commit, porque es lo que hace que los compañeros obtengan exactamente el mismo entorno.
Entonces, ¿cómo sabrán los compañeros qué claves necesitan? Lo habitual es subir un archivo .env.example con los nombres pero sin los valores. Un nuevo compañero lo copia a .env y rellena sus propias claves.
Un secreto que se ha subido a un commit no desaparece por borrarlo en el siguiente commit. El trabajo de Git es recordar cada estado anterior, así que la clave sigue en el commit anterior para siempre — cualquiera puede extraerla congit show. Una vez que una clave se ha subido a cualquier remoto, la única jugada segura es ir al servicio al que pertenece la clave, revocarla y crear una nueva. Por eso.envtiene que estar en.gitignoreantes de que el archivo se cree.
El primer commit, y buenos mensajes de commit
git add .
git commit -m "chore: scaffold project with uv"
[main (root-commit) 0f78e59] chore: scaffold project with uv
6 files changed, 35 insertions(+)
create mode 100644 .gitignore
create mode 100644 .python-version
create mode 100644 README.md
create mode 100644 main.py
create mode 100644 pyproject.toml
create mode 100644 uv.lock0f78e59 es la identidad del commit (el principio de su hash) — será distinto en tu máquina, porque la hora y el autor se mezclan en él.
Un mensaje de commit se escribe para un lector futuro — tú dentro de seis meses, y el revisor de hoy. Muchos equipos siguen un estilo común llamado Conventional Commits:
type(scope): short summary in the imperative- tipo indica qué tipo de cambio es:
feat(una capacidad nueva),fix(una corrección de un bug),docs,refactor,test,chore(mantenimiento) - ámbito es opcional, entre paréntesis, e indica qué parte del proyecto:
feat(triage): ... - resumen va en imperativo ("añade", "corrige", "elimina" — no "añadido"), en menos de 72 caracteres, sin punto final
Lado a lado, la diferencia es obvia:
wip
fixed stuff
Fixed the bug where priority returned normal for tickets that say OUTAGE in capitals
fix(triage): lowercase ticket text before keyword matchingLos tres primeros no dan ningún error — Git acepta cualquier mensaje. Pero en git log los dos primeros no dicen nada, y el tercero es tan largo que se corta en un listado de una línea. Al leer el último, un revisor sabe qué cambió y dónde. Si el «por qué» necesita más espacio, va en el cuerpo del mensaje, después de una línea en blanco.
Ramas: cada trabajo en su propia habitación
La rama main es lo más valioso del equipo: lo que hay en ella funciona y puede desplegarse en cualquier momento. Así que nadie trabaja directamente en main. Cada tarea tiene su propia rama nueva:
git switch -c feat/priority-score
Switched to a new branch 'feat/priority-score'Aquí se escribe triage.py:
def priority(text: str) -> str:
urgent_words = ("outage", "down", "breach")
if any(word in text for word in urgent_words):
return "high"
return "normal"git add triage.py
git commit -m "feat(triage): add keyword-based priority"
[feat/priority-score ded4456] feat(triage): add keyword-based priority
1 file changed, 5 insertions(+)
create mode 100644 triage.pyLos nombres de las ramas siguen el mismo estilo: feat/..., fix/..., docs/... — la lista te dice de un vistazo qué es cada una.
Una nota sobre la estrategia de ramas. En su momento, muchos equipos mantenían varias ramas de larga duración — develop, release, hotfix — y movían código entre ellas con reglas muy elaboradas. Hoy la mayoría de los equipos sigue el camino más sencillo: una sola main, y ramas de funcionalidad pequeñas y de vida corta que salen de ella, se revisan y se integran de nuevo en un día o dos. Cuanto más tiempo está una rama apartada, más avanza main y mayores son los conflictos cuando vuelve. Este curso sigue el camino sencillo.
Remotos: push y pull
Hasta ahora todo está en tu máquina. Para compartir con el equipo hace falta un repositorio remoto — normalmente en GitHub o un servicio similar. Al crear un repositorio ahí obtienes una dirección, y escribes:
git remote add origin https://github.com/<your-account>/ticket-triage.gitLas transcripciones de este capítulo se verificaron sin ningún servicio en línea: se creó un repositorio «bare» en una carpeta vecina y se usó como remoto. Para Git son lo mismo, y puedes practicar así en tu propia máquina:
git init --bare ../remote.git
git remote add origin ../remote.gitEl primer push necesita -u para indicar a qué rama remota está vinculada esta rama:
git push -u origin main
branch 'main' set up to track 'origin/main'.
To ../remote.git
* [new branch] main -> mainLa rama de funcionalidad se sube igual:
git push -u origin feat/priority-score
branch 'feat/priority-score' set up to track 'origin/feat/priority-score'.
To ../remote.git
* [new branch] feat/priority-score -> feat/priority-scoreAhora un compañero clona el repositorio:
git clone remote.git teammate
Cloning into 'teammate'...
done.Su carpeta tiene README.md, main.py, pyproject.toml, uv.lock (y los archivos ocultos) — pero no tiene .venv ni .env. Exactamente como debe ser: ejecuta uv sync para construir su propio entorno, y crea su propio .env a partir de .env.example.
Cuando la funcionalidad se fusiona en main (en el trabajo real, eso ocurre al fusionar el Pull Request — ver más abajo), el compañero trae el nuevo estado:
git pull
Updating 0f78e59..5f802b4
Fast-forward
triage.py | 5 +++++
1 file changed, 5 insertions(+)
create mode 100644 triage.py(La salida real empieza con una línea From ... que indica la ruta completa del remoto; aquí se omite.) Fast-forward significa que el compañero no tenía commits nuevos propios, así que Git simplemente adelantó su main.
Issues y Pull Requests
En una empresa, el trabajo suele empezar con un issue — una descripción numerada de un problema, escrita en el servicio de alojamiento de código. Por ejemplo: #12 — Las palabras clave en mayúsculas no se detectan (los tickets que dicen «OUTAGE» llegan como normales).
Entonces el ciclo sigue estos pasos:
- Una rama desde
main:git switch -c fix/uppercase-keywords - Commits pequeños y con sentido
- Subir la rama con push
- En la web del servicio, o desde la línea de comandos, abrir un Pull Request (PR) — una solicitud que dice «incorpora los cambios de mi rama a
main» - El revisor comenta, y tú respondes con nuevos commits en la misma rama
- Una vez aprobado y pasadas las comprobaciones automáticas (CI), el PR se fusiona y la rama se borra
Con la herramienta de línea de comandos de GitHub, gh, el paso 4 se ve así (su salida depende de tu cuenta y de tu repositorio, así que no se muestra aquí):
gh pr create --base main --title "fix(triage): detect uppercase keywords" --body-file PR.mdUna descripción de PR limpia (PR.md) responde a las tres preguntas del revisor — qué, por qué y cómo se probó:
## What
Lowercase ticket text before matching urgent keywords.
## Why
Tickets written in capitals ("OUTAGE") were scored as normal.
Closes #12
## How I tested
priority("Service DOWN") returned "normal" on main, "high" on this branch
uv run check_commits.py -> 0 of 2 commit messages need workLa línea Closes #12 es una pequeña comodidad: cuando el PR se fusiona en la rama principal, el servicio cierra solo el issue #12.
Cómo revisan los mentores
Cada tarea práctica de este curso se mirará con ojos de revisor, y en una empresa estas son las preguntas que surgen:
- ¿El PR es pequeño y de un solo propósito? Una funcionalidad, un bug o una refactorización — no las tres juntas. A partir de unos pocos cientos de líneas, la calidad de la revisión baja, porque la gente no puede mantener la atención a lo largo de un diff largo.
- ¿Los mensajes de commit se leen como un historial? Un historial lleno de
wipyfixno le sirve a nadie. - ¿Qué se ha colado que no debería?
.venv,.env,__pycache__, archivos de datos grandes — si alguno aparece en el diff, el PR se devuelve al autor. - ¿Qué debería estar ahí? Si se ha añadido un paquete nuevo, los cambios en
pyproject.tomlyuv.lockdeben estar en el diff. - ¿La descripción dice cómo se probó?
Si el mensaje del último commit es incorrecto, puedes cambiarlo antes de subirlo:
git commit --amend -m "docs: describe the project in README"
git log --oneline -1
a18cc1a docs: describe the project in READMECambiar un commit después de subirlo significa reescribir el historial, lo que causa problemas a los compañeros; en ese caso es mejor un commit nuevo. Muchos equipos fusionan los PR con «squash» — todos los commits de la rama se combinan en uno, y el título del PR pasa a ser su mensaje. Entonces el título del PR también debe seguir el mismo estilo.
Un ejemplo completo
Una de las tareas del revisor puede dejarse en manos de una máquina: comprobar que cada mensaje de commit de la rama sigue las reglas. Los equipos ejecutan comprobaciones así en CI, de modo que el resultado aparece en cuanto se abre un PR. El check_commits.py de abajo hace exactamente eso — ejecuta git log y lee solo los commits de la rama actual, los que no están en main:
import re
import subprocess
import sys
ALLOWED_TYPES = {"feat", "fix", "docs", "refactor", "test", "chore"}
SUBJECT = re.compile(r"^(?P<type>[a-z]+)(\([a-z0-9-]+\))?: (?P<summary>.+)$")
MAX_LENGTH = 72
VAGUE = {"wip", "fix", "update", "changes", "stuff", "misc"}
def problems_in(subject: str) -> list[str]:
found = []
if subject.strip().lower() in VAGUE:
found.append("too vague for a reviewer")
if len(subject) > MAX_LENGTH:
found.append(f"longer than {MAX_LENGTH} characters ({len(subject)})")
match = SUBJECT.match(subject)
if match is None:
found.append("missing 'type: summary' prefix")
elif match["type"] not in ALLOWED_TYPES:
found.append(f"unknown type '{match['type']}'")
if subject.endswith("."):
found.append("ends with a period")
return found
def branch_subjects(base: str) -> list[str]:
result = subprocess.run(
["git", "log", "--reverse", "--format=%s", f"{base}..HEAD"],
capture_output=True,
encoding="utf-8",
)
if result.returncode != 0:
raise SystemExit(f"git log failed: {result.stderr.strip()}")
return result.stdout.splitlines()
def main() -> int:
subjects = branch_subjects("main")
failures = 0
for subject in subjects:
problems = problems_in(subject)
print(("ok " if not problems else "FAIL") + " " + subject)
for problem in problems:
print(f" - {problem}")
failures += 1 if problems else 0
print(f"{failures} of {len(subjects)} commit messages need work")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())Para probarlo, se creó una rama desde main con cinco commits — algunos con mensajes deliberadamente malos. (--allow-empty crea un commit sin cambiar ningún archivo; para comprobar mensajes basta con eso. En el trabajo real, cada commit lleva un cambio de código.)
git switch -c fix/uppercase-keywords
git commit --allow-empty -m "wip"
git commit --allow-empty -m "Fixed the bug where priority returned normal for tickets that say OUTAGE in capitals"
git commit --allow-empty -m "fix(triage): lowercase text before matching."
git commit --allow-empty -m "feature: add tests"
git commit --allow-empty -m "test(triage): cover uppercase keywords"Luego, desde dentro del repositorio:
uv run check_commits.pyFAIL wip
- too vague for a reviewer
- missing 'type: summary' prefix
FAIL Fixed the bug where priority returned normal for tickets that say OUTAGE in capitals
- longer than 72 characters (84)
- missing 'type: summary' prefix
FAIL fix(triage): lowercase text before matching.
- ends with a period
FAIL feature: add tests
- unknown type 'feature'
ok test(triage): cover uppercase keywords
4 of 5 commit messages need workEl código de salida del programa es 1, porque al menos un mensaje ha fallado. Si todo está bien, es 0.
Las decisiones de este script:
main..HEAD — este rango de dos puntos significa «commits en HEAD que no están en main». Dicho de otra forma, solo el trabajo propio de esta rama. Comprobar todo el historial haría fallar tu PR por commits de hace un año escritos por otras personas. --reverse los ordena del más antiguo al más reciente, el orden en que se hizo el trabajo.
--format=%s — %s significa solo la primera línea del commit (el asunto). Sin hash, fecha ni autor — así no hay nada que analizar; cada línea es un mensaje.
encoding="utf-8" — con text=True, Python usa la codificación por defecto del sistema operativo, que en Windows a menudo no es UTF-8. Si alguien escribe un mensaje de commit en bengalí o con un emoji, la salida puede romperse. Indicar la codificación elimina esa incertidumbre.
Su propio mensaje en lugar de check=True — cuando git falla (se ejecuta fuera de un repositorio, o no existe ninguna rama llamada main), el script muestra el propio mensaje de error de git en lugar de un traceback largo de Python. Pasar una cadena a SystemExit hace que Python la imprima en stderr y se detenga con código de salida 1.
El código de salida — CI no lee la salida; solo mira si el programa terminó con 0. Por eso main() devuelve un número, y sys.exit se lo pasa al sistema operativo.
Cuando falla
Author identity unknown seguido de fatal: unable to auto-detect email address Git no sabe a nombre de quién poner el commit. Ejecuta una vez los dos comandos git config --global user.name ... y user.email ... del inicio del capítulo.
error: src refspec main does not match any Todavía no hay commits, así que la rama main no existe realmente — no hay nada que subir. Haz primero git add y git commit, y luego push. El mismo mensaje aparece si la rama se llama en realidad master; comprueba en qué rama estás con git branch.
fatal: The current branch feat/x has no upstream branch. Se está subiendo por primera vez una rama nueva, y Git no sabe a qué rama remota enviarla. El propio mensaje da la solución: git push --set-upstream origin feat/x (o git push -u origin feat/x, en forma corta). Después, basta con git push.
! [rejected] main -> main (fetch first) Alguien ha subido cambios a la misma rama desde tu último fetch. Primero integra su trabajo con git pull, y luego haz push. No lo «arregles» con git push --force — eso borra el trabajo de tu compañero.
warning: in the working copy of 'main.py', LF will be replaced by CRLF the next time Git touches it En Windows esto es una advertencia, no un error. Windows y macOS/Linux terminan las líneas con caracteres distintos, y Git te avisa de que las reconciliará. El commit se hizo bien.
.env se subió por error Ejecuta git rm --cached .env — mantiene el archivo en el disco y solo lo quita del seguimiento de Git (la salida es rm '.env') — luego añade .env a .gitignore y haz commit. Pero recuerda que la clave sigue en el commit anterior. Si ya se ha subido, no hay más opción segura que revocar la clave y crear una nueva.
Paso 4 de 6 — Predice
Comprueba lo que has entendido
Tres mensajes se comprueban con el patrón SUBJECT del capítulo. ¿Qué se imprime?
import re
SUBJECT = re.compile(r"^(?P<type>[a-z]+)(\([a-z0-9-]+\))?: (?P<summary>.+)$")
for subject in ["fix: handle empty input", "Fix: handle empty input", "fix:handle empty input"]:
print(SUBJECT.match(subject) is not None)- A`True`, `False`, `True`
- B`True`, `True`, `True`
- C`True`, `True`, `False`
- D`True`, `False`, `False`
Un compañero ha «arreglado» esta función en check_commits.py. Ahora CI está en verde incluso en una rama con commits wip y feature: add tests, y la salida es 0 of 0 commit messages need work. ¿Dónde está el error?
import subprocess
def branch_subjects(base: str) -> list[str]:
result = subprocess.run(
["git", "log", "--reverse", "--format=%s", f"HEAD..{base}"],
capture_output=True,
encoding="utf-8",
)
if result.returncode != 0:
raise SystemExit(f"git log failed: {result.stderr.strip()}")
return result.stdout.splitlines()- AEl rango está al revés: `HEAD..main` significa «en `main` pero no en esta rama», así que no devuelve ninguno de los commits propios de la rama; debería ser `main..HEAD`
- B`--reverse` descarta commits; quitarlo los mostrará
- CLa comprobación de `returncode` descarta todos los commits; necesita `check=True`
- DCon `encoding="utf-8"`, `git` no produce ninguna salida; necesita `text=True`
Una descripción de PR contiene Closes #12. ¿Qué hace?
- ACuando el PR se fusiona en la rama principal, el servicio de alojamiento de código cierra solo el issue #12 y mantiene enlazados los dos
- BEl issue #12 se cierra en cuanto se abre el PR
- CNada — es solo una nota para el revisor; ninguna máquina la lee
- DGit elimina del historial el commit número 12
Jugar es parte de Pro
+30 XP te esperan
Juega este capítulo con Pro
Leer es gratis y el capítulo está abierto entero. Los primeros 5 capítulos de cada curso gratuito se pueden jugar gratis. Desde el capítulo 6, responder las preguntas, ganar XP y hacer crecer tu racha es parte de Pro.
Desbloquea el juego con ProTu turno
Crea un proyecto en tu propia máquina y haz el ciclo completo una vez:
- Crea el proyecto con
uv init my-triage --python 3.12, añade.enva.gitignore, guarda un archivo.env.example(que contenga soloLLM_API_KEY=), confirma quegit status --shortno muestra.envni.venv, y haz el primer commit - Añade un repositorio bare como remoto (
git init --bare ../my-triage-remote.git) y haz push demain - Crea una rama llamada
feat/priority-scorey añade eltriage.pyy elcheck_commits.pyde este capítulo en exactamente tres commits, cada uno siguiendo las reglas; sube la rama con push - Escribe un
PR.mdcon las secciones What, Why y How I tested — y, si tienes una cuenta en un servicio de alojamiento de código, abre allí un PR real con esta descripción
En la rama, uv run check_commits.py debe terminar exactamente con esta línea, precedida de tres líneas ok:
0 of 3 commit messages need workLuego haz a propósito un commit malo (git commit --allow-empty -m "wip"), ejecútalo de nuevo para ver qué dos problemas detecta, y corrígelo con git commit --amend -m "...". O prueba esto: cambia main..HEAD por HEAD..main en el script, mira qué imprime, y averigua por qué ese es el tipo de error más peligroso para una comprobación de CI.
Paso 6 de 6
Reto — el quiz del capítulo
Diez preguntas de fácil a difícil. Las últimas son difíciles a propósito.
Juega con Pro