Git, branch e Pull Request — come lavorano i team
Da git init a push e pull, tenere .venv e .env fuori con .gitignore, branch di funzionalità piccoli, messaggi di commit puliti e Pull Request — più uno strumento Python che legge git log per controllare i commit.
Come vuoi imparare?
Il problema che stiamo risolvendo
Un AI engineer ha passato una settimana su un progetto di smistamento dei ticket: ha cambiato il prompt, ampliato la lista delle parole chiave, riscritto una funzione. Venerdì i risultati sono peggiori di lunedì. Quale modifica è la colpevole? La cartella contiene triage.py, triage_v2.py, triage_final.py e triage_final_REAL.py — e nessuno sa cosa è cambiato in quale file.
Nel frattempo un collega che modifica lo stesso file in una cartella condivisa ha cancellato il proprio lavoro. E un mese fa qualcuno ha incollato una chiave API direttamente nel codice per un test veloce, e attraverso quella cartella è finita sotto gli occhi di altre dieci persone.
Tutti e tre i problemi hanno un'unica risposta, che quasi ogni team di sviluppo al mondo usa: tenere la cronologia con Git, lavorare su ogni pezzo di lavoro nel proprio branch e, prima che qualcosa arrivi al codice principale, farlo rivedere da qualcun altro in una Pull Request. Questo capitolo insegna l'intero ciclo — e si chiude scrivendo uno strumento Python che automatizza uno dei compiti del reviewer.
Alla fine di questo capitolo sarai in grado di
- Tenere e condividere la cronologia di un progetto con
git init,add,commit,pushepull - Tenere
.venve.envfuori dal repository con.gitignore, e sapere cosa fare quando uno dei due viene committato per errore - Lavorare su branch di funzionalità piccoli e di breve durata, e aprire una Pull Request pulita
- Scrivere messaggi di commit nel modo in cui li leggono i reviewer
- Scrivere uno strumento Python che legge
git logtramitesubprocesse controlla i messaggi di commit
Prerequisiti: Il percorso per AI engineer e gli ambienti di sviluppo con uv — soprattutto uv init e uv run.
Configurazione iniziale
Git registra chi ha fatto ogni commit. Quindi la prima volta ti presenti una sola volta, e conviene dire a Git di chiamare main il branch principale dei nuovi repository, perché oggi è quello che usa la maggior parte dei team:
git config --global user.name "Nadia Karim"
git config --global user.email "[email protected]"
git config --global init.defaultBranch mainUsa il tuo nome e l'email con cui sei registrato sul servizio che ospita il tuo codice. Senza l'ultima riga, le versioni più vecchie di Git chiamano il branch principale master — funziona, ma non corrisponde al resto del team.
Tre posti: la cartella, lo staging e la cronologia
Nel capitolo precedente uv init ha creato da solo un repository Git. Anche l'esempio di questo capitolo parte allo stesso modo:
uv init ticket-triage --python 3.12
cd ticket-triageGit ragiona in tre posti, e ogni comando sposta qualcosa da uno all'altro:
- Il working tree — la tua cartella, dove modifichi i file
- L'area di staging — la lista di ciò che entrerà nel prossimo commit;
git addmette le cose qui - La cronologia — la catena dei commit;
git committrasforma l'area di staging in un commit permanente
Perché un passaggio separato di staging? Perché a metà di un lavoro potresti aver modificato cinque file, due dei quali correggono un bug e tre aggiungono una funzionalità. Lo staging ti permette di dividerli in due commit separati e sensati — e per un reviewer questo vale molto.
Il comando per vedere a che punto sei è git status, o in forma breve git status --short.
.gitignore: cosa non entra mai nel repository
I progetti di AI quasi sempre hanno un file .env con le chiavi API. Supponiamo che il progetto ottenga un file così (sk-... qui è solo un segnaposto, non una chiave vera):
LLM_API_KEY=sk-...Il .gitignore creato da uv init contiene .venv ma non .env. Quindi il primo lavoro è aggiungerlo in fondo:
# Secrets
.envOra esegui una volta uv run main.py (così vengono creati .venv e uv.lock) e controlla lo stato:
git status --short
?? .gitignore
?? .python-version
?? README.md
?? main.py
?? pyproject.toml
?? uv.lock?? significa che Git vede il file ma non lo sta ancora tracciando. La notizia importante è ciò che non compare nella lista: niente .venv, niente .env. Entrambi sono nella cartella, ma Git finge di non vederli. (.venv ha in realtà due livelli di protezione: quando uv crea l'ambiente, vi inserisce anche un piccolo .gitignore che contiene solo *.) E uv.lock c'è — va committato, perché è il modo in cui i colleghi ottengono esattamente lo stesso ambiente.
Quindi come faranno i colleghi a sapere di quali chiavi hanno bisogno? Il modo solito è committare un file .env.example con i nomi ma non i valori. Chi arriva nuovo lo copia in .env e inserisce le proprie chiavi.
Un segreto committato non viene rimosso cancellandolo nel commit successivo. Il compito di Git è ricordare ogni stato precedente, quindi la chiave resta nel commit precedente per sempre — chiunque può recuperarla congit show. Una volta che una chiave è stata pushata su un remote qualsiasi, l'unica mossa sicura è andare sul servizio a cui appartiene la chiave, revocarla e crearne una nuova. Per questo.envdeve essere in.gitignoreprima che il file venga creato.
Il primo commit, e messaggi di commit validi
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 è l'identificativo del commit (l'inizio del suo hash) — sarà diverso sulla tua macchina, perché nell'hash entrano l'orario e l'autore.
Un messaggio di commit si scrive per un lettore futuro — tu tra sei mesi, e il reviewer di oggi. Molti team seguono uno stile comune chiamato Conventional Commits:
type(scope): short summary in the imperative- type indica che tipo di modifica è:
feat(una nuova funzionalità),fix(la correzione di un bug),docs,refactor,test,chore(manutenzione) - scope è facoltativo, tra parentesi, e indica quale parte del progetto:
feat(triage): ... - summary è all'imperativo ("aggiungi", "correggi", "rimuovi" — non "aggiunto"), entro 72 caratteri, senza punto finale
Affiancati, la differenza è evidente:
wip
fixed stuff
Fixed the bug where priority returned normal for tickets that say OUTAGE in capitals
fix(triage): lowercase ticket text before keyword matchingI primi tre non producono errori — Git accetta qualsiasi messaggio. Ma in git log i primi due non dicono nulla, e il terzo è così lungo che viene tagliato in un elenco su una riga. Leggendo l'ultimo, un reviewer sa cosa è cambiato e dove. Se il "perché" ha bisogno di più spazio, va nel corpo del messaggio, dopo una riga vuota.
Branch: ogni lavoro nella sua stanza
Il branch main è la cosa più preziosa del team: ciò che c'è dentro funziona e può essere rilasciato in ogni momento. Quindi nessuno lavora direttamente su main. Ogni attività ha un nuovo branch:
git switch -c feat/priority-score
Switched to a new branch 'feat/priority-score'Qui viene scritto 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.pyI nomi dei branch seguono lo stesso stile: feat/..., fix/..., docs/... — l'elenco ti dice a colpo d'occhio di cosa si tratta.
Una parola sulla strategia di branching. Un tempo molti team tenevano diversi branch di lunga durata — develop, release, hotfix — e spostavano il codice tra di essi con regole complicate. Oggi la maggior parte dei team segue la strada più semplice: un solo main, e piccoli branch di funzionalità di breve durata che si staccano da lì, vengono revisionati e uniti di nuovo al branch principale entro un giorno o due. Più a lungo un branch resta separato, più main va avanti, e più grandi sono i conflitti quando torna. Questo corso segue la strada semplice.
Remote: push e pull
Finora tutto è sulla tua macchina. Per condividere il lavoro con un team serve un repository remote — di solito su GitHub o su un servizio simile. Creando lì un repository ottieni un indirizzo, e scrivi:
git remote add origin https://github.com/<your-account>/ticket-triage.gitLe trascrizioni di questo capitolo sono state verificate senza alcun servizio online: un repository "bare" è stato creato in una cartella vicina e usato come remote. Per Git sono la stessa cosa, e puoi esercitarti anche tu sulla tua macchina in questo modo:
git init --bare ../remote.git
git remote add origin ../remote.gitIl primo push richiede -u per dire a quale branch remoto è collegato questo branch:
git push -u origin main
branch 'main' set up to track 'origin/main'.
To ../remote.git
* [new branch] main -> mainIl branch di funzionalità segue la stessa strada:
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-scoreOra un collega clona il repository:
git clone remote.git teammate
Cloning into 'teammate'...
done.La sua cartella contiene README.md, main.py, pyproject.toml, uv.lock (e i file nascosti) — ma nessun .venv e nessun .env. Esattamente come deve essere: il collega esegue uv sync per costruire il proprio ambiente, e crea il proprio .env a partire da .env.example.
Dopo che la funzionalità viene unita a main (nel lavoro reale succede quando la Pull Request viene mergiata — vedi sotto), il collega scarica il nuovo stato:
git pull
Updating 0f78e59..5f802b4
Fast-forward
triage.py | 5 +++++
1 file changed, 5 insertions(+)
create mode 100644 triage.py(L'output reale inizia con una riga From ... che riporta il percorso completo del remote; qui è stata omessa.) Fast-forward significa che il collega non aveva commit suoi, quindi Git ha semplicemente spostato avanti il suo main.
Issue e Pull Request
In un'azienda il lavoro di solito parte da un'issue — la descrizione numerata di un problema, scritta sul servizio che ospita il codice. Per esempio: #12 — Le parole chiave in maiuscolo non vengono rilevate (i ticket con "OUTAGE" arrivano come normali).
Poi il ciclo va così:
- Un branch da
main:git switch -c fix/uppercase-keywords - Commit piccoli e sensati
- Fai il push del branch
- Sul sito del servizio, oppure dalla riga di comando, apri una Pull Request (PR) — una richiesta che dice "porta le modifiche del mio branch in
main" - Il reviewer lascia dei commenti, e tu rispondi con nuovi commit sullo stesso branch
- Una volta approvata e superati i controlli automatici (CI), la PR viene mergiata e il branch cancellato
Con lo strumento a riga di comando di GitHub, gh, il passo 4 appare così (l'output dipende dal tuo account e dal tuo repository, quindi non è mostrato qui):
gh pr create --base main --title "fix(triage): detect uppercase keywords" --body-file PR.mdUna descrizione di PR pulita (PR.md) risponde alle tre domande del reviewer — cosa, perché e come è stata testata:
## 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 riga Closes #12 è una piccola comodità: quando la PR viene mergiata nel branch predefinito, il servizio chiude da solo l'issue #12.
Come i mentor fanno la review
Ogni attività pratica del corso verrà guardata con gli occhi di un reviewer, e in azienda sono queste le domande che emergono:
- La PR è piccola e ha un solo scopo? Una funzionalità, un bug o un refactoring — non tutti e tre insieme. Oltre poche centinaia di righe la qualità della review cala, perché le persone non riescono a mantenere l'attenzione su un diff lungo.
- I messaggi di commit si leggono come una storia? Una cronologia piena di
wipefixnon serve a nessuno. - È entrato qualcosa che non doveva?
.venv,.env,__pycache__, file di dati grandi — se uno di questi compare nel diff, la PR torna indietro. - Cosa dovrebbe esserci? Se è stato aggiunto un nuovo pacchetto, le modifiche a
pyproject.tomleuv.lockdevono essere nel diff. - La descrizione dice come è stato testato?
Se il messaggio dell'ultimo commit è sbagliato, puoi cambiarlo prima del push:
git commit --amend -m "docs: describe the project in README"
git log --oneline -1
a18cc1a docs: describe the project in READMECambiare un commit dopo il push significa riscrivere la cronologia, il che crea problemi ai colleghi; in quel caso è meglio un nuovo commit. Molti team mergiano le PR con "squash" — tutti i commit del branch vengono combinati in uno solo, e il titolo della PR diventa il suo messaggio. Quindi anche il titolo della PR deve seguire lo stesso stile.
Un esempio completo
Uno dei compiti del reviewer può essere affidato a una macchina: verificare che ogni messaggio di commit del branch rispetti le regole. I team fanno girare controlli così in CI, in modo che il risultato compaia non appena viene aperta una PR. Il check_commits.py qui sotto fa esattamente questo — esegue git log e legge solo i commit del branch corrente, quelli che non sono su 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())Per provarlo, è stato creato un branch da main con cinque commit — alcuni con messaggi volutamente sbagliati. (--allow-empty crea un commit senza modificare alcun file; basta per controllare i messaggi. Nel lavoro reale ogni commit porta una modifica al codice.)
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"Poi, dall'interno del repository:
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 workIl codice di uscita del programma è 1, perché almeno un messaggio è fallito. Con tutto a posto è 0.
Le scelte di questo script:
main..HEAD — questo intervallo con due punti significa "i commit in HEAD che non sono in main". In altre parole, solo il lavoro proprio di questo branch. Controllare tutta la cronologia farebbe fallire la tua PR per colpa di commit vecchi di un anno scritti da altri. --reverse li ordina dal più vecchio, cioè nell'ordine in cui il lavoro è stato fatto.
--format=%s — %s indica solo la prima riga del commit (il subject). Niente hash, data o autore — quindi non c'è nulla da analizzare; ogni riga è un messaggio.
encoding="utf-8" — con text=True, Python usa la codifica predefinita del sistema operativo, che su Windows spesso non è UTF-8. Se qualcuno scrive un messaggio di commit in bengalese o con un emoji, l'output può rompersi. Dichiarare la codifica elimina questa incertezza.
Un messaggio proprio invece di check=True — quando git fallisce (eseguito fuori da un repository, o senza un branch chiamato main), lo script mostra il messaggio di errore di git invece di un lungo traceback Python. Passare una stringa a SystemExit fa stampare a Python quella stringa su stderr e terminare con codice di uscita 1.
Il codice di uscita — la CI non legge l'output; controlla solo se il programma è terminato con 0. Quindi main() restituisce un numero, e sys.exit lo passa al sistema operativo.
Quando si rompe
Author identity unknown seguito da fatal: unable to auto-detect email address Git non sa a nome di chi mettere il commit. Esegui una volta i due comandi git config --global user.name ... e user.email ... dell'inizio del capitolo.
error: src refspec main does not match any Non ci sono ancora commit, quindi il branch main non esiste davvero — non c'è nulla da pushare. Prima git add e git commit, poi il push. Lo stesso messaggio compare se il branch si chiama davvero master; controlla su quale branch sei con git branch.
fatal: The current branch feat/x has no upstream branch. Un nuovo branch viene pushato per la prima volta, e Git non sa a quale branch remoto inviarlo. Il messaggio stesso dà la correzione: git push --set-upstream origin feat/x (o git push -u origin feat/x in forma breve). Dopo, basta git push.
! [rejected] main -> main (fetch first) Qualcun altro ha fatto push sullo stesso branch da quando hai fatto l'ultimo fetch. Prima integra il suo lavoro con git pull, poi fai il push. Non "risolvere" con git push --force — cancellerebbe il lavoro del tuo collega.
warning: in the working copy of 'main.py', LF will be replaced by CRLF the next time Git touches it Su Windows questo è un avviso, non un errore. Windows e macOS/Linux terminano le righe con caratteri diversi, e Git ti avvisa che li riconcilierà. Il commit è andato a buon fine.
.env è stato committato per errore Esegui git rm --cached .env — mantiene il file su disco e lo rimuove solo dal tracciamento di Git (l'output è rm '.env') — poi aggiungi .env a .gitignore e fai commit. Ma ricorda che la chiave è ancora nel commit precedente. Se è stata pushata, non c'è altra scelta sicura se non revocare la chiave e crearne una nuova.
Passo 4 di 6 — Prevedi
Verifica cosa hai capito
Tre messaggi vengono controllati con il pattern SUBJECT del capitolo. Cosa viene stampato?
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 collega ha "sistemato" questa funzione in check_commits.py. Ora la CI è verde anche su un branch con commit wip e feature: add tests, e l'output è 0 of 0 commit messages need work. Dov'è il bug?
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()- AL'intervallo è invertito: `HEAD..main` significa "in `main` ma non su questo branch", quindi non tornano i commit propri del branch; dovrebbe essere `main..HEAD`
- B`--reverse` elimina i commit; rimuoverlo li farà comparire
- CIl controllo `returncode` scarta ogni commit; serve `check=True`
- DCon `encoding="utf-8"`, `git` non produce output; serve `text=True`
Una descrizione di PR contiene Closes #12. Cosa fa?
- AQuando la PR viene mergiata nel branch predefinito, il servizio che ospita il codice chiude da solo l'issue #12 e mantiene collegate le due cose
- BL'issue #12 viene chiusa non appena la PR viene aperta
- CNiente — è solo una nota per il reviewer; nessuna macchina la legge
- DGit elimina dalla cronologia il commit numero 12
Giocare fa parte di Pro
+30 XP ti aspettano
Gioca questo capitolo con Pro
Leggere è gratis e l’intero capitolo è aperto. I primi 5 capitoli di ogni corso gratuito si giocano gratis. Dal capitolo 6, rispondere alle domande, guadagnare XP e far crescere la serie fa parte di Pro.
Sblocca il gioco con ProTocca a te
Crea un progetto sulla tua macchina e percorri una volta tutto il ciclo:
- Crea il progetto con
uv init my-triage --python 3.12, aggiungi.enva.gitignore, tieni un file.env.example(che contiene soloLLM_API_KEY=), verifica chegit status --shortnon mostri né.envné.venv, e fai il primo commit - Aggiungi un repository bare come remote (
git init --bare ../my-triage-remote.git) e fai il push dimain - Crea un branch chiamato
feat/priority-scoree aggiungi iltriage.pye ilcheck_commits.pydi questo capitolo in esattamente tre commit, ognuno conforme alle regole; fai il push del branch - Scrivi un
PR.md— What, Why, How I tested — e, se hai un account su un servizio che ospita codice, apri lì una PR vera con questa descrizione
Sul branch, uv run check_commits.py dovrebbe terminare esattamente con questa riga, preceduta da tre righe ok:
0 of 3 commit messages need workPoi fai di proposito un commit sbagliato (git commit --allow-empty -m "wip"), eseguilo di nuovo per vedere quali due problemi vengono rilevati, e correggilo con git commit --amend -m "...". Oppure prova questo: nello script sostituisci main..HEAD con HEAD..main, guarda cosa stampa, e capisci perché è il tipo di errore più pericoloso per un controllo CI.
Passo 6 di 6
Sfida — il quiz del capitolo
Dieci domande dalla più facile alla più difficile. Le ultime sono difficili di proposito.
Gioca con Pro