Salta al contenuto principale

102 - ML-Ops: Git, la macchina del tempo del tuo progetto

Git da zero per chi fa machine learning: staging e commit, branch per gli esperimenti, ripristino, reflog e bisect per trovare quando il modello è peggiorato.

Cosa ne pensi di questo articolo?

102 - ML-Ops: Git, la macchina del tempo del tuo progetto
Machine Learning Engineerfedericocalo.dev

In Breve

Git da zero per chi fa machine learning: staging e commit, branch per gli esperimenti, ripristino, reflog e bisect per trovare quando il modello è peggiorato.

Git è un sistema che registra la storia di una cartella: ogni volta che lo decidi, ne scatta una fotografia (un commit) con un messaggio e un autore, e ti permette di tornare a qualunque fotografia passata, di lavorare su più versioni in parallelo e di capire chi ha cambiato cosa. In un progetto di machine learning serve a una cosa in più: quando un modello peggiora, ti dice in quale modifica è successo. Questa lezione usa Git 2.53, ma i comandi che vedrai sono stabili da anni. Viene dopo l'introduzione al modulo, che spiega perché il codice è la prima delle cinque cose implicite da rendere esplicite.

Il vocabolario di Git

Git ha un lessico suo, e senza non si legge nessun messaggio d'errore. Il repository (repo) è la cartella del progetto più la sua storia nascosta, che Git tiene in una sottocartella .git. Un commit è una fotografia del progetto in un istante, con messaggio, autore e data; è identificato da un hash (come 119e2f5) calcolato dal suo contenuto, quindi cambia un carattere e cambia l'hash, e ne bastano le prime cifre per indicarlo. La working tree sono i file come sono ora sul disco; la staging area (o index) è il vassoio dove appoggi le modifiche che vuoi mettere nella prossima fotografia.

Un branch è una linea di commit che avanza per conto suo, ridotta a un'etichetta che punta all'ultimo commit; HEAD è il "tu sei qui" di Git, cioè il branch (o il commit) su cui stai lavorando. Il merge riunisce in un branch le modifiche fatte su un altro, il diff è l'elenco preciso delle righe cambiate tra due versioni (+ aggiunte, - tolte) e un remoto è una copia del repository su un'altra macchina, per esempio GitHub, di cui parla la lezione successiva.

Il percorso di una modifica, dal file che tocchi alla copia condivisa, passa per quattro luoghi. Il diagramma mostra le frecce in avanti (i comandi che spostano il lavoro verso la storia) e quelle tratteggiate all'indietro (i comandi per disfare):

Il percorso di una modifica in Git: working tree, staging area, repository locale, repository remotogit addgit commitgit pushWorking treei file che modifichiStagingcosa entra nel commitRepositoryla storia dei commitRemotocopia condivisa (GitHub)--> git restore: Staging -> Working tree--> git restore --staged: Repository -> Staging

L'analogia: i salvataggi di un videogioco

Pensa a un gioco con salvataggi manuali. Salvi prima del boss, giochi, perdi: ricarichi. Salvi in più slot per provare due strategie senza perdere nessuna delle due. Git funziona così, con una differenza importante: ogni salvataggio ha un'etichetta che scrivi tu ("feat: aggiunge soglia di AUC") e, quando lo guardi, ti mostra ciò che è cambiato rispetto al precedente. Dentro, come vedremo più sotto, ogni commit è una fotografia completa che riusa i file invariati, quindi puoi averne migliaia senza occupare spazio inutile.

Il meccanismo: tre aree, non due

La confusione dei principianti nasce dal fatto che Git ha tre posti dove vive il tuo lavoro, non due. Nella working tree ci sono i file così come li vedi e li modifichi. La staging area contiene la lista di modifiche che hai scelto di includere nella prossima fotografia. Il repository è la sequenza di commit già scattati. La staging area serve a decidere cosa entra in un commit: se hai modificato tre file ma due sono lavoro non finito, aggiungi alla fotografia solo il file pronto. Un commit dovrebbe raccontare una cosa sola.

Il giro minimo, che userai mille volte, ha cinque comandi: crei il repository, guardi lo stato, porti un file in staging, scatti la fotografia e leggi la storia.

git init -b main                 # crea il repository nella cartella corrente
git status                       # cosa è cambiato, cosa è in staging
git add src/churn/model.py       # porta UN file in staging
git commit -m "feat: aggiunge il parametro C alla regressione logistica"
git log --oneline                # la storia, una riga per commit

Il flag -b main sceglie il nome del primo branch; senza, Git usa l'impostazione init.defaultBranch, che dipende dalla macchina. Prima del primo commit imposta chi sei, una volta sola per macchina: senza nome e indirizzo Git si rifiuta di committare, perché ogni commit porta un autore.

git config --global user.name "Il tuo nome"
git config --global user.email "tu@esempio.it"

Che cosa metti nel commit, e che cosa no

La regola è semplice: nel repository va ciò che si può rigenerare o che serve a rigenerare. Va il codice, il file di dipendenze, i test, la documentazione. Non va ciò che è grande, derivato o segreto:

  • Dati grezzi e dataset grandi: Git copia tutta la storia, e un file da 800 MB modificato dieci volte pesa per sempre nel repository. Per i dati si usano strumenti dedicati, come spiegato nell'articolo sul versionamento di dataset e modelli con DVC e lakeFS.
  • Modelli addestrati (.joblib, .pt): sono output, si registrano in un registry (MLflow, model registry e alias).
  • Ambienti virtuali (.venv/): si ricreano dal lockfile (UV).
  • Segreti e file .env: una chiave committata va considerata compromessa anche se la cancelli dopo, perché resta nella storia.

Le quattro voci hanno la stessa radice: tutto ciò che si deriva da altro o non deve circolare sta fuori, e il repository resta piccolo e pubblicabile. Il caso dei segreti è diverso dagli altri tre, perché non è un problema di peso ma di sicurezza, e per questo è l'unico irreversibile. Si dichiara tutto questo in un file .gitignore alla radice. Un esempio ragionevole per un progetto ML:

.venv/
__pycache__/
.ipynb_checkpoints/
data/raw/
mlruns/
mlflow.db
*.joblib
.env

Branch: provare senza rompere

Un branch è una linea di sviluppo parallela, leggera come un'etichetta. Per provare un'idea (per esempio un diverso valore di regolarizzazione) crei un branch, lavori, e se funziona lo riporti sulla linea principale; se non funziona lo butti senza aver toccato nulla.

git switch -c esperimento/c-regolarizzazione   # crea e passa al nuovo branch
# ... modifichi, provi, fai commit ...
git switch main                                # torni alla linea principale
git merge esperimento/c-regolarizzazione       # integri, se l'esperimento è buono
git branch -d esperimento/c-regolarizzazione   # ripulisci

Un branch per esperimento è una buona abitudine: la storia resta leggibile e puoi confrontare due idee con git diff main..altro-branch. Il prezzo è che i branch si accumulano, e quelli già integrati vanno potati con git branch -d, che rifiuta di cancellare un branch non ancora integrato e ti protegge così da una perdita.

Tornare indietro, in tre livelli di gravità

SituazioneComandoEffetto
Hai modificato un file e vuoi annullare le modifiche non ancora in staginggit restore percorso/fileIl file torna all'ultimo commit. Le modifiche perse non sono recuperabili.
Hai fatto git add di troppogit restore --staged percorso/fileToglie dalla staging, il file resta com'è.
Un commit già fatto era sbagliatogit revert <hash>Crea un nuovo commit che annulla il vecchio. La storia resta onesta.

Esiste anche git reset --hard, che riscrive il presente buttando il lavoro non committato: usalo solo sapendo cosa stai buttando. La regola pratica è che i comandi che aggiungono storia (commit, revert) sono sicuri, quelli che la riscrivono richiedono attenzione, soprattutto se qualcun altro ha già ricevuto quei commit.

Una sessione completa, comando per comando

La teoria si capisce davvero guardando cosa stampa Git. Ho eseguito l'intera sessione che segue, due volte, in una cartella temporanea con Git 2.53 e LC_ALL=C (per avere i messaggi in inglese, uniformi). Per rendere uguali anche gli hash ho fissato data e identità dei commit; sul tuo computer le date saranno diverse e quindi gli hash non coincideranno con quelli riportati: contano il formato e la struttura, non le cifre.

git init -b main
git config user.name "Test" && git config user.email "a@b.c"

git init -b main crea il repository (.git) e chiama main il primo branch. Le due righe config dicono a Git chi firma i commit; qui le imposto solo per questo repository (senza --global).

mkdir src
printf 'C = 1.0\n' > src/params.py
printf '.venv/\n*.joblib\n.env\n' > .gitignore
git status --short
# ?? .gitignore
# ?? src/

Il ?? significa "non tracciato": Git vede i file ma non li conosce ancora. --short è la forma compatta di git status: due colonne, a sinistra lo stato nella staging, a destra nell'area di lavoro.

git add src/params.py .gitignore
git status --short
# A  .gitignore
# A  src/params.py
git commit -m "feat: parametro C"
git log --oneline
# 119e2f5 feat: parametro C

Dopo l'add la A nella colonna di sinistra dice "aggiunto alla staging". Il commit crea la fotografia; git log --oneline ne mostra una riga per commit, e quei sette caratteri sono l'inizio dell'hash completo.

printf 'C = 0.1\n' > src/params.py
git diff
# diff --git a/src/params.py b/src/params.py
# index 7d6dcb9..c844a8e 100644
# --- a/src/params.py
# +++ b/src/params.py
# (qui Git stampa l'intestazione del blocco, omessa)
# -C = 1.0
# +C = 0.1
git add -A
git diff                      # non stampa nulla: la modifica è passata in staging
git diff --staged --stat
#  src/params.py | 2 +-
#  1 file changed, 1 insertion(+), 1 deletion(-)

Il diff mostra una riga tolta (-) e una aggiunta (+): hai cambiato un numero. Dopo git add -A, git diff non mostra più nulla, perché confronta la working tree con la staging; per vedere ciò che sta per entrare nel commit si usa git diff --staged. Regola: prima di ogni commit, leggi git diff --staged. Scopri file inclusi per errore (un .env, un file da 800 MB) quando costa niente toglierli.

Il .gitignore in azione

git commit -m "tune: C a 0.1"
touch model.joblib .env
git status --short            # non stampa nulla: i due file sono ignorati
git check-ignore -v model.joblib .env
# .gitignore:2:*.joblib	model.joblib
# .gitignore:3:.env	.env

Dopo aver creato i due file, git status --short non stampa nulla. git check-ignore -v dice perché: ogni riga riporta il file, il numero di riga e la regola che lo esclude. Quando un file "non vuole" entrare in Git, o quando uno ci è entrato che non doveva, è lo strumento giusto per capire. Attenzione: .gitignore non toglie i file già tracciati. Per un segreto già committato non basta aggiungerlo: serve rimuoverlo dal tracciamento e sostituire il segreto.

Cos'è davvero un commit

git cat-file -t HEAD
# commit
git cat-file -p HEAD
# tree ede37614c8d100b0f34433119d5638b2d9b67d84
# parent 119e2f57b09e0b3b415d9c319b0cdedf9fb8d6a7
# author Test <a@b.c> 1794477720 +0000
# committer Test <a@b.c> 1794477720 +0000
#
# tune: C a 0.1

Il secondo comando stampa il contenuto grezzo: una riga tree (l'istantanea dei file), una parent (l'hash del commit precedente), author e committer con data, e il messaggio. È la prova che la storia è una catena: ogni commit punta al suo genitore. Per questo cambiare un vecchio commit cambia tutti gli hash successivi, ed è il motivo per cui riscrivere la storia condivisa è delicato.

Branch e merge: due forme

git switch -c esperimento/c-grande
printf 'C = 10.0\n' > src/params.py
git commit -am "try: C a 10"
git switch main
git merge esperimento/c-grande
# Updating bccbf4b..873077d
# Fast-forward
#  src/params.py | 2 +-
git log --oneline --graph
# * 873077d try: C a 10
# * bccbf4b tune: C a 0.1
# * 119e2f5 feat: parametro C

commit -am aggiunge in staging i file già tracciati e committa in un passo. Siccome su main nel frattempo non era stato fatto nulla, il merge è un fast-forward: Git sposta semplicemente l'etichetta main in avanti, e il grafico resta una linea retta. Se invece main ha avuto commit suoi nel frattempo, Git crea un commit di fusione con due genitori. Per vederlo ho creato un secondo branch esp2 due commit indietro, ho aggiunto un commit su ciascuna delle due linee e ho fatto git merge --no-edit esp2:

git log --oneline --graph
# *   9bd855c Merge branch 'esp2'
# |\
# | * 3e6e314 try: C a 2
# * | c59bc7a docs: nota
# * | 2a4e707 Revert "try: C a 10"
# * | 873077d try: C a 10
# |/
# * bccbf4b tune: C a 0.1
# * 119e2f5 feat: parametro C

Il grafico si legge dall'alto: il commit di merge 9bd855c riunisce due linee, quella di esp2 (un solo commit) e quella di main, che nel frattempo aveva accumulato altri tre commit. Le due righe che si dividono e si ricongiungono sono esattamente "due persone hanno lavorato in parallelo". Nel blocco compare già il commit Revert "try: C a 10", che nasce dal comando della sezione seguente.

Disfare, con tre comandi diversi per tre situazioni

git revert --no-edit HEAD
# [main 2a4e707] Revert "try: C a 10"
#  1 file changed, 1 insertion(+), 1 deletion(-)

Ha creato un nuovo commit, Revert "try: C a 10", che applica il contrario del commit indicato: il file è tornato a C = 0.1, e la storia contiene sia l'errore sia la sua correzione. È la scelta giusta per commit già condivisi, perché non riscrive nulla. --no-edit accetta il messaggio predefinito invece di aprire l'editor.

printf 'C = 5\n' > src/params.py
git add src/params.py
git restore --staged src/params.py     # toglie dalla staging; il file resta modificato
git status --short
#  M src/params.py
git restore src/params.py              # butta le modifiche del file
cat src/params.py
# C = 2

Sono i due livelli di restore del diagramma: --staged sposta all'indietro la modifica dalla staging all'area di lavoro (lo stato M, con lo spazio a sinistra, dice "modificato ma non in staging"); senza --staged, il file torna com'era nell'ultimo commit. Qui vale C = 2 perché è il valore che il merge precedente ha portato su main. Il secondo comando distrugge le modifiche non committate: non c'è cestino.

La rete di sicurezza: reflog

Hai cancellato un branch per sbaglio, o fatto un reset di troppo? Git tiene un diario di dove è stato puntato HEAD, il reflog. Ecco la prova, passo per passo: faccio un commit, lo "butto" con un reset, lo ritrovo.

printf 'C = 77\n' > src/params.py
git commit -am "try: C a 77"
# [main d2f61a2] try: C a 77
git reset --hard HEAD~1            # lo "butto": il branch torna indietro di uno
git log --oneline -1
# 9bd855c Merge branch 'esp2'      (il commit non c'è più)
git reflog -3
# 9bd855c HEAD@{0}: reset: moving to HEAD~1
# d2f61a2 HEAD@{1}: commit: try: C a 77
# 9bd855c HEAD@{2}: checkout: moving from c59bc7a2e35172e7692a217a82cbfee8f894d549 to main
git switch -c recuperato d2f61a2   # ricrea un branch da un hash trovato nel reflog
cat src/params.py
# C = 77

La seconda riga del reflog mostra il commit "perso", d2f61a2, con la sua descrizione; crearci sopra un branch lo riporta in vita e il contenuto è tornato. Tre osservazioni. Primo: funziona perché Git non cancella subito gli oggetti non più raggiunti; secondo la documentazione di git config, le voci di reflog non più raggiungibili scadono dopo 30 giorni (gc.reflogExpireUnreachable) e le altre dopo 90 (gc.reflogExpire), salvo diversa impostazione. Secondo: il reflog è locale, non viene condiviso con i remoti, quindi ti salva da un tuo errore, non da quello di un altro. Terzo: funziona solo per ciò che era stato committato; le modifiche non committate cancellate da reset --hard o da restore non sono nel reflog, perché non sono mai state una fotografia.

Il caso ML: trovare quando il modello è peggiorato con bisect

Immagina di accorgerti che l'AUC richiesta dal controllo è scesa, ma non sai in quale delle ultime cinquanta modifiche. Git può cercarla per bisezione: gli indichi un commit "buono" e uno "cattivo", lui prova quello a metà, tu (o uno script) dici se è buono, e in pochi passi isola il colpevole. Con n commit servono circa log₂(n) prove: su 50 commit bastano sei prove, non cinquanta.

Ecco la versione automatica, eseguita su un repository di prova con otto commit, in cui dal quinto in poi la soglia nel file cfg passa da 0.70 a 0.50:

git bisect start HEAD $(git rev-list --max-parents=0 HEAD)   # cattivo = HEAD, buono = primo commit
# Bisecting: 3 revisions left to test after this (roughly 2 steps)
git bisect run sh -c 'grep -q "AUC_MIN=0.70" cfg'           # exit 0 = buono, diverso da 0 = cattivo
# ...
# c116d4704afcdc0bec2dde3e3853534360182487 is the first bad commit
# bisect found first bad commit
git bisect reset                                             # torna dove eri

Git ha provato i commit 4, 6 e 5 (tre prove su otto commit) e ha indicato il quinto come primo commit sbagliato, quello che porta la soglia a 0.50. Nel caso reale il comando dentro bisect run sarebbe il tuo test: uv run pytest tests/test_model.py -q. Il test è lo strumento che dice a Git cosa vuol dire "buono": un motivo concreto in più per scrivere test, argomento di pytest e di come si testa un modello.

Trappola: git bisect reset non accetta opzioni come -q; il suo argomento opzionale è un commit, e con -q Git risponde che non è un commit valido (provato con Git 2.53). Se dimentichi il reset resti in uno stato di bisezione.

Il perché di Git: il problema che risolve davvero

Prima di Git, il modo normale di "versionare" era copiare la cartella e cambiarle nome. Funziona per un lavoro solitario e di breve durata, e si rompe in tre modi prevedibili. Primo, non sai cosa distingue due copie senza confrontarle file per file. Secondo, non puoi lavorare in due sullo stesso progetto: o vi alternate, o ognuno ha la sua copia e poi qualcuno deve riunirle a mano. Terzo, non puoi tornare in un punto intermedio che non hai copiato apposta. Git risolve questi tre problemi con un'unica idea: la storia del progetto è una catena di fotografie, ciascuna con un genitore, e il lavoro in parallelo è una ramificazione di quella catena.

Una proprietà di Git ha conseguenze pratiche che spesso passano inosservate: è distribuito. Ogni clone di un repository contiene l'intera storia, non solo l'ultima versione. Ne derivano tre cose utili: puoi lavorare senza rete (commit, confronti, bisect sono tutti locali); ogni clone è anche una copia di sicurezza completa; e il "server" (GitHub, di cui parla la lezione su GitHub) è soltanto un clone che tutti hanno concordato di considerare condiviso, non un'autorità tecnica.

Come Git conserva le cose (e perché i branch costano quasi nulla)

Nell'esempio di prima, git cat-file -p HEAD ha mostrato un commit con una riga tree e una parent. Dietro c'è un modello semplice con tre tipi di oggetti, tutti identificati dall'hash del loro contenuto: il blob (il contenuto di un file), il tree (l'elenco di nomi di file e cartelle con l'hash dei rispettivi blob e sotto-tree) e il commit (un puntatore a un tree, più il genitore, l'autore e il messaggio). Un commit non è una "differenza" rispetto al precedente: è una fotografia completa dell'albero dei file, ma poiché i file invariati hanno lo stesso hash e vengono riusati, la fotografia costa soltanto ciò che è cambiato. È come un album in cui le foto che non cambiano vengono incollate come riferimento alla pagina precedente.

Da qui una conseguenza importante: un branch è soltanto un file di testo che contiene l'hash di un commit. Nella sessione di prova .git/refs/heads/main pesava 41 byte (wc -c): quaranta cifre esadecimali più l'a capo. Creare un branch non copia nulla: aggiunge una etichetta. Per questo "un branch per ogni esperimento" costa praticamente zero. HEAD, a sua volta, è un puntatore al branch su cui sei: quando fai un commit, Git sposta l'etichetta del branch corrente sul nuovo commit.

Lo stato "detached HEAD" e perché spaventa

Posizionarsi su un commit che non è la punta di un branch (git checkout HEAD~1) produce questo messaggio, con git status:

git checkout -q HEAD~1
git status
# HEAD detached at c59bc7a
# nothing to commit, working tree clean

"HEAD staccato" significa che stai guardando una fotografia vecchia senza un'etichetta di branch che ti segua. È uno stato normale e utile per ispezionare il passato (rieseguire i test su una versione vecchia, per esempio). Il pericolo è un altro: se in questo stato fai nuovi commit, nessun branch li punta, e quando ti sposti altrove sembrano spariti (restano recuperabili con il reflog, come visto sopra). Regola semplice: se vuoi lavorare da un punto vecchio, git switch -c nuovo-branch prima di fare modifiche; se vuoi solo guardare, guarda e poi git switch main.

Dare un nome ai rilasci: i tag, e come legano un modello al codice

Un branch si sposta a ogni commit; un tag è un'etichetta che resta dov'è. Si usa per marcare i punti che contano: una versione rilasciata, il codice con cui hai addestrato il modello consegnato.

git tag -a v1.0 -m "primo rilascio"
git commit --allow-empty -m "chore: dopo il tag"
git describe --tags
# v1.0-1-g6090d0d
git tag -n1
# v1.0            primo rilascio

tag -a crea un tag "annotato" (con messaggio, autore e data); git tag -n1 li elenca con la prima riga del messaggio. git describe --tags è molto utile: dopo un commit successivo al tag risponde v1.0-1-g6090d0d, cioè "un commit dopo v1.0, e l'hash corrente comincia per 6090d0d". È una descrizione leggibile e univoca della versione del codice, che puoi scrivere nei log o come tag di un esperimento in MLflow (tracciare gli esperimenti): da quella stringa chiunque risale al codice esatto.

Uno sguardo critico: git stash e la regola di questo progetto

Quando provi a cambiare branch con modifiche non committate che confliggono, Git risponde così (messaggio copiato dall'esecuzione):

error: Your local changes to the following files would be overwritten by checkout:
	nota.txt
Please commit your changes or stash them before you switch branches.
Aborting

Il suggerimento standard è git stash: un comando che mette da parte le modifiche in un'area temporanea per riapplicarle poi. Questo progetto, nelle sue regole di lavoro, lo vieta: lo stash sposta il lavoro "in un'area invisibile a status e log", e il rischio è dimenticarlo lì. La regola alternativa è semplice: committa (anche un commit provvisorio, da sistemare poi) oppure copia i file con un percorso esplicito. È una scelta di progetto e non una verità universale (molti team usano lo stash), ma è un buon esempio di come funzionano le regole di squadra: nascono da un costo concreto (lavoro perso o dimenticato) e si scrivono dove tutti le vedono. Per un ML engineer che ha mezz'ora di addestramento non committato nella cartella di lavoro, il principio vale in ogni caso: ciò che non è in un commit non esiste, per Git.

Scrivere commit che un'altra persona può usare

Il messaggio è per il te di tra sei mesi, che deve capire perché, non cosa (il cosa lo mostra il diff). Questo repository usa il formato tipo(ambito): oggetto, con tipi come feat, fix, refactor, docs, test, chore. Un esempio reale dalla sua storia: fix(routes): redirectTo assoluti (commit 87c0ed473a, verificato con git log --oneline). È una convenzione, descritta dalla specifica Conventional Commits, che rende la storia leggibile e filtrabile. Qualche criterio, con esempi:

MessaggioProblemaVersione migliore
fixNon dice cosafix(model): evita la divisione per zero con clienti senza reclami
modifiche variePiù cose insieme, non separabilidue commit, uno per cambiamento
wipProvvisorio lasciato nella storiasistemarlo prima di condividere (--amend o rebase, se non è ancora pubblico)
aggiornato il modelloNon dice perché né di quantoperf(model): C da 1.0 a 0.1, AUC 0.7142 a 0.7134 (equivalente, modello più stabile)

L'ultimo esempio è tipico del machine learning: un commit che cambia un parametro dovrebbe riportare la metrica prima e dopo (le cifre dell'esempio sono inventate, a scopo didattico). Quando, tra sei mesi, un git bisect ti porterà in quel commit, il messaggio sarà la prima cosa che leggi.

Domande che ci si pone al primo giorno

"Ho committato nel branch sbagliato." Se non hai ancora fatto push: git switch -c giusto (crea il branch giusto dal commit corrente), poi riporta main indietro con git switch main && git reset --hard HEAD~1. Il reset --hard riscrive: usalo solo su commit mai pubblicati, e solo dopo aver controllato con git log che il commit sia anche sul branch nuovo.

"Ho sbagliato il messaggio dell'ultimo commit." git commit --amend -m "messaggio corretto", ancora una volta solo se non è stato pubblicato (cambia l'hash).

"Quanto spesso devo committare?" Ogni volta che hai completato un'unità di lavoro che ha senso da sola e che lascia il progetto in uno stato funzionante: un test che passa, un parametro aggiunto, una correzione. Commit piccoli e frequenti rendono bisect e revert efficaci, ma richiedono disciplina nel messaggio. Se non riesci a scrivere il messaggio in una riga senza usare "e", probabilmente sono due commit.

"Devo mettere i notebook in Git?" Sì, ma sono file difficili: un .ipynb contiene anche gli output (grafici in base64), e un diff diventa illeggibile. Pratiche comuni: ripulire gli output prima del commit, oppure tenere la logica in file .py e usare il notebook solo come interfaccia di esplorazione.

Cosa fare con i file troppo grandi

Il motivo tecnico per tenere dati e modelli fuori da Git è l'abbinamento di due fatti: Git conserva la storia completa in ogni clone, e i file binari grandi non si comprimono né si "differenziano" bene. Un modello da 200 MB committato dieci volte può pesare gigabyte in ogni clone, per sempre (anche cancellandolo dopo, perché resta nella storia): è un ordine di grandezza dell'argomento, non una misura. Le alternative sono di tre famiglie: strumenti che mettono nel repository un piccolo file puntatore e conservano il contenuto altrove (Git LFS, DVC, di cui parla la serie sul versionamento di dataset e modelli); un archivio di oggetti con versioni; o, per i modelli, il registry di MLflow. In tutti i casi il principio è lo stesso: Git versiona il codice che produce l'artefatto e un riferimento a lui, non l'artefatto.

Cosa sai fare adesso

Sai portare un file dalla working tree alla storia passando per la staging, e leggere git diff --staged prima di ogni commit. Sai decidere che cosa tenere fuori dal repository e dichiararlo in .gitignore, provare un'idea su un branch e riunirla con un merge. Sai disfare con il comando giusto per la situazione (restore, revert), ritrovare un commit "perso" con il reflog e trovare con git bisect run la modifica che ha rotto un test. La prossima lezione porta questa storia su un server condiviso: GitHub, remoto, branch protetti e pull request.

Nella stessa serie trovi anche ML-Ops: usare bene l'open source, dalle licenze al contributo, ML-Ops: variabili d'ambiente e segreti fuori dal codice e ML-Ops: il deploy, dove gira il modello e come scegliere.

Fonti

  • Documentazione ufficiale di Git - i comandi git-bisect, git-reflog, git-restore, git-revert e git-tag usati nella sessione; verificati anche con le pagine di manuale locali di Git 2.53 (man git-config per i tempi di scadenza del reflog), consultata il 2026-11-12
  • Pro Git, di Scott Chacon e Ben Straub - il modello a oggetti (blob, tree, commit) e i branch come puntatori, consultata il 2026-11-12
  • Conventional Commits - il formato tipo(ambito): oggetto dei messaggi, consultata il 2026-11-12

Tutti i comandi della sessione sono stati eseguiti due volte con Git 2.53.0 in una cartella temporanea fuori dal repository, con LC_ALL=C, identità e date dei commit fissate, e le uscite (hash compresi) sono risultate identiche. Gli hash che vedi sul tuo computer saranno diversi. Dove nel testo compare "un file da 800 MB" o "un modello da 200 MB" si tratta di ordini di grandezza dell'argomento, non di misure; l'AUC dell'esempio di messaggio di commit è inventata.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti