In Article 1 you learned every Git command on its own. Here we build the muscle memory by using all of them together, in a realistic ML project. We'll build a small but complete Iris flower classifier, and at every step pause to explain what the Git operation is actually doing and why, the same way you'd use it on a real job.
By the end you'll have a fully version-controlled ML project on GitHub, hands-on experience with commits, branches, merging, reverting, and stashing, and a workflow you can carry straight into your own projects.
The project
Task: classify Iris flowers into three species (setosa, versicolor, virginica) from sepal and petal measurements.
Stack: Python, scikit-learn, pandas, matplotlib.
Here's the structure we're building toward:
iris_classifier/
├── .gitignore
├── README.md
├── requirements.txt
├── data/
│ └── iris.csv (ignored by git)
├── src/
│ ├── __init__.py
│ ├── preprocess.py
│ ├── train.py
│ └── evaluate.py
├── notebooks/
│ └── exploration.ipynb
└── models/ (ignored by git)
└── trained_model.pkl
Act 1
Project initialization
Step 1.1 — create the project and initialize Git
# create the project directory and move into it
mkdir iris_classifier
cd iris_classifier
# initialize as a Git repository
git init
Output
Initialized empty Git repository in /iris_classifier/.git/
You now have an empty Git repository. Nothing is tracked yet.
# confirm Git is watching (status on an empty repo)
git status
Output
On branch main
No commits yet
nothing to commit (create/copy files and start working)
Step 1.2 — create .gitignore first, always first
Make this a habit: create .gitignore before you create anything else. If you accidentally track a file and commit it, getting it back out of history is genuinely painful.
touch .gitignore
Open it and add:
# Python
__pycache__/
*.pyc
*.pyo
.Python
venv/
.venv/
*.egg-info/
# Jupyter notebooks
.ipynb_checkpoints/
# Data files (store separately — DVC, S3, a shared drive)
data/
*.csv
*.tsv
# Trained models (can be large)
models/
*.pkl
*.joblib
*.h5
# Secrets
.env
*.key
# OS
.DS_Store
git status
Output
On branch main
No commits yet
Untracked files:
(use "git add <file>..." to include in what will be committed)
.gitignore
nothing added to commit but untracked files present
Stage and commit:
git add .gitignore
git commit -m "Add .gitignore for Python and ML artifacts"
Output
[main (root-commit) 4a1b2c3] Add .gitignore for Python and ML artifacts
1 file changed, 20 insertions(+)
create mode 100644 .gitignore
First commit made. Two things worth noticing: root-commit means this is the very first commit in the repo, and 4a1b2c3 is the short SHA (yours will be different).
Step 1.3 — create the README
The README is the front page of your project on GitHub.
# README.md
# Iris Flower Classifier
A machine learning project that classifies Iris flowers into 3 species
(Setosa, Versicolor, Virginica) using the classic Iris dataset.
## Project Structure
- `src/` — source code for preprocessing, training, and evaluation
- `notebooks/` — exploratory data analysis
- `requirements.txt` — Python dependencies
## Setup
```bash
pip install -r requirements.txt
```
## Usage
```bash
python src/train.py
python src/evaluate.py
```
## Results
| Model | Test Accuracy |
|-------|--------------|
| Logistic Regression | TBD |
| Random Forest | TBD |
git add README.md
git commit -m "Add project README with structure and setup instructions"
Step 1.4 — project structure and requirements.txt
# create the directory structure
mkdir -p src notebooks data models
# placeholder files so Git tracks otherwise-empty directories
touch src/__init__.py
touch notebooks/.gitkeep
# requirements.txt
cat > requirements.txt << 'EOF'
scikit-learn==1.4.0
pandas==2.2.0
numpy==1.26.4
matplotlib==3.8.2
seaborn==0.13.2
joblib==1.3.2
EOF
git add requirements.txt src/__init__.py notebooks/.gitkeep
git commit -m "Add project structure and Python dependencies"
Check the log so far:
git log --oneline
Output
9d3f1a2 Add project structure and Python dependencies
7b2e4c1 Add project README with structure and setup instructions
4a1b2c3 Add .gitignore for Python and ML artifacts
The project's history is already telling a clean story, three commits in.
Act 2
Building the baseline model, on main
Step 2.1 — write the preprocessing code
# src/preprocess.py
"""
preprocess.py — Load and prepare the Iris dataset for training.
"""
import pandas as pd
from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
def load_data():
"""Load the Iris dataset and return as a DataFrame."""
iris = load_iris()
df = pd.DataFrame(iris.data, columns=iris.feature_names)
df['target'] = iris.target
df['species'] = df['target'].map({
0: 'setosa',
1: 'versicolor',
2: 'virginica'
})
return df
def split_data(df, test_size=0.2, random_state=42):
"""Split into train/test sets."""
X = df[['sepal length (cm)', 'sepal width (cm)',
'petal length (cm)', 'petal width (cm)']]
y = df['target']
X_train, X_test, y_train, y_test = train_test_split(
X, y,
test_size=test_size,
random_state=random_state,
stratify=y # keeps class distribution balanced across splits
)
print(f"Train size: {len(X_train)}, Test size: {len(X_test)}")
return X_train, X_test, y_train, y_test
if __name__ == "__main__":
df = load_data()
print(df.head())
print(f"Dataset shape: {df.shape}")
split_data(df)
git add src/preprocess.py
git commit -m "Add data loading and train-test split with stratification"
Step 2.2 — write the baseline training code
# src/train.py
"""
train.py — Train a Logistic Regression baseline model.
"""
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
import joblib
import os
from preprocess import load_data, split_data
def train_model(X_train, y_train):
"""Build and train a logistic regression pipeline."""
pipeline = Pipeline([
('scaler', StandardScaler()),
('classifier', LogisticRegression(
max_iter=200,
random_state=42
))
])
pipeline.fit(X_train, y_train)
return pipeline
def save_model(model, path='models/logistic_regression.pkl'):
"""Save trained model to disk."""
os.makedirs('models', exist_ok=True)
joblib.dump(model, path)
print(f"Model saved to {path}")
if __name__ == "__main__":
df = load_data()
X_train, X_test, y_train, y_test = split_data(df)
print("Training logistic regression...")
model = train_model(X_train, y_train)
save_model(model)
print("Training complete!")
git add src/train.py
git commit -m "Add logistic regression baseline with StandardScaler pipeline"
Step 2.3 — write the evaluation code
# src/evaluate.py
"""
evaluate.py — Evaluate the trained model on the test set.
"""
import joblib
import numpy as np
from sklearn.metrics import (
accuracy_score,
classification_report,
confusion_matrix
)
import matplotlib.pyplot as plt
import seaborn as sns
from preprocess import load_data, split_data
def evaluate_model(model, X_test, y_test):
"""Print evaluation metrics."""
y_pred = model.predict(X_test)
accuracy = accuracy_score(y_test, y_pred)
print(f"\n{'='*40}")
print(f"Test Accuracy: {accuracy:.4f} ({accuracy*100:.2f}%)")
print(f"{'='*40}\n")
print("Classification Report:")
print(classification_report(
y_test, y_pred,
target_names=['setosa', 'versicolor', 'virginica']
))
return y_pred, accuracy
def plot_confusion_matrix(y_test, y_pred):
"""Plot and save confusion matrix."""
cm = confusion_matrix(y_test, y_pred)
plt.figure(figsize=(8, 6))
sns.heatmap(
cm,
annot=True,
fmt='d',
cmap='Blues',
xticklabels=['setosa', 'versicolor', 'virginica'],
yticklabels=['setosa', 'versicolor', 'virginica']
)
plt.title('Confusion Matrix — Logistic Regression')
plt.ylabel('True Label')
plt.xlabel('Predicted Label')
plt.tight_layout()
plt.savefig('confusion_matrix.png', dpi=150)
print("Confusion matrix saved.")
if __name__ == "__main__":
model = joblib.load('models/logistic_regression.pkl')
df = load_data()
_, X_test, _, y_test = split_data(df)
y_pred, accuracy = evaluate_model(model, X_test, y_test)
plot_confusion_matrix(y_test, y_pred)
git add src/evaluate.py
git commit -m "Add model evaluation with accuracy, classification report, and confusion matrix"
Check the history:
git log --oneline
Output
e5c1d23 Add model evaluation with accuracy, classification report, and confusion matrix
3f8b7a4 Add logistic regression baseline with StandardScaler pipeline
1d2e6c5 Add data loading and train-test split with stratification
9d3f1a2 Add project structure and Python dependencies
7b2e4c1 Add project README with structure and setup instructions
4a1b2c3 Add .gitignore for Python and ML artifacts
A complete baseline, in six clean, meaningful commits.
Act 3
Experimenting with a new model, on a feature branch
main stays clean and working the whole time.Step 3.1 — create a feature branch
# create and switch to a new branch
git switch -c feature/random-forest-model
Output
Switched to a new branch 'feature/random-forest-model'
git branch
Output
* feature/random-forest-model ← asterisk marks the current branch
main
The new branch is a copy of main at this exact point. All six commits exist here too. Any new commits from here only land on this branch.
Step 3.2 — add the Random Forest training script
# src/train_rf.py
"""
train_rf.py — Train a Random Forest classifier.
"""
from sklearn.ensemble import RandomForestClassifier
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
from sklearn.model_selection import cross_val_score
import joblib
import numpy as np
import os
from preprocess import load_data, split_data
def train_random_forest(X_train, y_train, n_estimators=100, max_depth=None):
"""Build and train a Random Forest pipeline."""
pipeline = Pipeline([
('scaler', StandardScaler()),
('classifier', RandomForestClassifier(
n_estimators=n_estimators,
max_depth=max_depth,
random_state=42,
n_jobs=-1 # use all CPU cores
))
])
# cross-validation on the training set for a more reliable estimate
cv_scores = cross_val_score(pipeline, X_train, y_train, cv=5, scoring='accuracy')
print(f"5-Fold CV Accuracy: {cv_scores.mean():.4f} (+/- {cv_scores.std():.4f})")
pipeline.fit(X_train, y_train)
return pipeline
def save_model(model, path='models/random_forest.pkl'):
"""Save trained model to disk."""
os.makedirs('models', exist_ok=True)
joblib.dump(model, path)
print(f"Model saved to {path}")
if __name__ == "__main__":
df = load_data()
X_train, X_test, y_train, y_test = split_data(df)
print("Training Random Forest with cross-validation...")
model = train_random_forest(X_train, y_train, n_estimators=100)
save_model(model)
print("Random Forest training complete!")
git add src/train_rf.py
git commit -m "Add Random Forest classifier with 5-fold cross-validation"
Step 3.3 — update evaluate.py to support both models
# see what's changed since branching
git diff main feature/random-forest-model
Update the if __name__ == "__main__": block in src/evaluate.py:
if __name__ == "__main__":
import sys
model_path = sys.argv[1] if len(sys.argv) > 1 else 'models/logistic_regression.pkl'
model = joblib.load(model_path)
df = load_data()
_, X_test, _, y_test = split_data(df)
y_pred, accuracy = evaluate_model(model, X_test, y_test)
plot_confusion_matrix(y_test, y_pred)
git diff src/evaluate.py
Output
- model = joblib.load('models/logistic_regression.pkl')
+ import sys
+ model_path = sys.argv[1] if len(sys.argv) > 1 else 'models/logistic_regression.pkl'
+ model = joblib.load(model_path)
git add src/evaluate.py
git commit -m "Make evaluate.py accept model path as CLI argument"
Step 3.4 — view branch differences
# commits in the feature branch but not in main
git log main..feature/random-forest-model --oneline
Output
9a8b3c1 Make evaluate.py accept model path as CLI argument
7c4d2e5 Add Random Forest classifier with 5-fold cross-validation
git log --oneline --graph --all
Output
* 9a8b3c1 (HEAD -> feature/random-forest-model) Make evaluate.py accept model path as CLI argument
* 7c4d2e5 Add Random Forest classifier with 5-fold cross-validation
* e5c1d23 (main) Add model evaluation with accuracy, classification report, and confusion matrix
* 3f8b7a4 Add logistic regression baseline with StandardScaler pipeline
* 1d2e6c5 Add data loading and train-test split with stratification
* 9d3f1a2 Add project structure and Python dependencies
* 7b2e4c1 Add project README with structure and setup instructions
* 4a1b2c3 Add .gitignore for Python and ML artifacts
main and feature/random-forest-model diverge from commit e5c1d23.
Act 4
Merging the feature branch
The Random Forest works well. Time to bring it into main.
Step 4.1 — switch to main and merge
git switch main
git merge feature/random-forest-model
Output — fast-forward, because main had no new commits
Updating e5c1d23..9a8b3c1
Fast-forward
src/evaluate.py | 5 +++--
src/train_rf.py | 45 +++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 48 insertions(+), 2 deletions(-)
create mode 100644 src/train_rf.py
# it's merged, so the branch can go
git branch -d feature/random-forest-model
git log --oneline
Output
9a8b3c1 (HEAD -> main) Make evaluate.py accept model path as CLI argument
7c4d2e5 Add Random Forest classifier with 5-fold cross-validation
e5c1d23 Add model evaluation with accuracy, classification report, and confusion matrix
...
Act 5
Simulating a bug, and rolling it back
stratify=y from the train-test split. Class balance breaks. The bug gets committed and pushed. You need to fix it.Step 5.1 — introduce the bug, to practice reverting it
# buggy version — missing stratify=y
X_train, X_test, y_train, y_test = train_test_split(
X, y,
test_size=test_size,
random_state=random_state
# stratify=y ← accidentally removed!
)
git add src/preprocess.py
git commit -m "Update train-test split parameters"
# a vague message — exactly the kind of thing a reviewer should flag
There's now a bug sitting in your history. Say you notice the model's accuracy dropped.
Step 5.2 — find it with git log and git diff
git log --oneline -5
Output
b3c2d1e (HEAD -> main) Update train-test split parameters
9a8b3c1 Make evaluate.py accept model path as CLI argument
...
git show b3c2d1e
Output
- stratify=y
Found it: stratify=y was removed.
Step 5.3 — revert the bug, safely
Since this commit may already be on GitHub and shared with others, reach for git revert rather than git reset. Revert adds a new commit that undoes the change, and leaves history intact.
git revert HEAD --no-edit
Output
[main 4d3e2c1] Revert "Update train-test split parameters"
1 file changed, 1 insertion(+), 1 deletion(-)
git log --oneline -5
Output
4d3e2c1 (HEAD -> main) Revert "Update train-test split parameters"
b3c2d1e Update train-test split parameters ← the bug commit is still here
9a8b3c1 Make evaluate.py accept model path as CLI argument
The bug commit is still visible in the log. git revert doesn't hide history, it adds a correction on top. That's what makes it honest and safe to use on shared branches.
Act 6
The stash: switching context mid-work
Step 6.1 — stash the half-done work
# a half-written SVM file
cat > src/train_svm.py << 'EOF'
"""
train_svm.py — SVM classifier (work in progress!)
"""
from sklearn.svm import SVC
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
# TODO: add cross-validation
# TODO: add hyperparameter grid
def train_svm(X_train, y_train):
EOF
git status
Output
On branch main
Untracked files:
src/train_svm.py
git stash push -m "WIP: SVM classifier, halfway through, no CV yet"
git status
Output
On branch main
nothing to commit, working tree clean
git stash list
Output
stash@{0}: On main: WIP: SVM classifier, halfway through, no CV yet
Step 6.2 — fix the production bug
git switch -c hotfix/fix-feature-scaling-bug
The actual fix, in preprocess.py:
# return copies, not views, so callers can't accidentally mutate the split
X_train = X_train.copy()
X_test = X_test.copy()
git add src/preprocess.py
git commit -m "Fix: return copies from split_data to prevent mutation bugs"
# merge the fix back to main right away
git switch main
git merge hotfix/fix-feature-scaling-bug
git branch -d hotfix/fix-feature-scaling-bug
Step 6.3 — restore the stashed work
git stash pop
Output
On branch main
Untracked files:
src/train_svm.py
Dropped stash@{0}: On main: WIP: SVM classifier, halfway through, no CV yet
The SVM file is back exactly where you left it. Pick the work back up.
Act 7
Pushing to GitHub and opening a pull request
Step 7.1 — create a GitHub repository
- On github.com, click "New repository."
- Name it
iris-classifier. - Make it public, so it's visible on your portfolio.
- Skip the README, since you already have one locally.
- Click "Create repository."
GitHub shows you the setup commands. Use these:
# connect the local repo to GitHub
git remote add origin git@github.com:YOUR_USERNAME/iris-classifier.git
git remote -v
Output
origin git@github.com:YOUR_USERNAME/iris-classifier.git (fetch)
origin git@github.com:YOUR_USERNAME/iris-classifier.git (push)
git push -u origin main
Output
Enumerating objects: 28, done.
Counting objects: 100% (28/28), done.
...
Branch 'main' set up to track remote branch 'main' from 'origin'.
The project is live on GitHub.
Step 7.2 — simulate a pull request workflow
This is how teams actually collaborate: create a feature branch, push it, open a PR.
git switch -c feature/svm-classifier
Finish src/train_svm.py:
"""
train_svm.py — Support Vector Machine classifier with grid search.
"""
from sklearn.svm import SVC
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
from sklearn.model_selection import GridSearchCV, cross_val_score
import joblib
import os
from preprocess import load_data, split_data
def train_svm(X_train, y_train):
"""Train SVM with hyperparameter tuning via GridSearchCV."""
pipeline = Pipeline([
('scaler', StandardScaler()),
('classifier', SVC(random_state=42, probability=True))
])
param_grid = {
'classifier__C': [0.1, 1, 10, 100],
'classifier__kernel': ['rbf', 'linear'],
'classifier__gamma': ['scale', 'auto']
}
grid_search = GridSearchCV(
pipeline,
param_grid,
cv=5,
scoring='accuracy',
n_jobs=-1,
verbose=1
)
grid_search.fit(X_train, y_train)
print(f"Best Parameters: {grid_search.best_params_}")
print(f"Best CV Score: {grid_search.best_score_:.4f}")
return grid_search.best_estimator_
def save_model(model, path='models/svm_model.pkl'):
os.makedirs('models', exist_ok=True)
joblib.dump(model, path)
print(f"Model saved to {path}")
if __name__ == "__main__":
df = load_data()
X_train, X_test, y_train, y_test = split_data(df)
print("Training SVM with GridSearchCV...")
model = train_svm(X_train, y_train)
save_model(model)
git add src/train_svm.py
git commit -m "Add SVM classifier with GridSearchCV hyperparameter tuning"
git push -u origin feature/svm-classifier
Output
...
* [new branch] feature/svm-classifier -> feature/svm-classifier
Branch 'feature/svm-classifier' set up to track remote branch 'feature/svm-classifier' from 'origin'.
On GitHub, you'll see a prompt on the repo page: "Compare & pull request." Click it and fill in the form:
Title: Add SVM classifier with GridSearchCV tuning
Description:
## What does this PR do?
Adds a Support Vector Machine (SVM) classifier with automated
hyperparameter tuning using GridSearchCV (5-fold CV).
## Hyperparameters explored
- C: [0.1, 1, 10, 100]
- kernel: ['rbf', 'linear']
- gamma: ['scale', 'auto']
## Testing
Run: python src/train_svm.py
After review and approval, click "Merge pull request" on GitHub. Then, locally:
# pull the merged changes back down
git switch main
git pull origin main
# delete the remote feature branch (GitHub may already have)
git push origin --delete feature/svm-classifier
# delete the local one too
git branch -d feature/svm-classifier
Act 8
Tagging a release
The project now has three models. Time to mark this point as v1.0.
Step 8.1 — create the tag
# an annotated tag, with a message
git tag -a v1.0 -m "First stable release: Logistic Regression, Random Forest, and SVM classifiers"
git tag
Output
v1.0
git show v1.0
Output
tag v1.0
Tagger: Your Name <you@example.com>
Date: Mon Jan 15 10:30:00 2024 +0800
First stable release: Logistic Regression, Random Forest, and SVM classifiers
commit e4d5c6b (HEAD -> main, tag: v1.0)
...
git push origin v1.0
On GitHub, this shows up as a proper Release. It's a nice, professional touch on a portfolio.
Act 9
Handling a merge conflict
src/preprocess.py at the same time. When you try to merge, Git finds a genuine conflict.Step 9.1 — simulate the conflict
git switch -c branch-a
Change test_size in split_data():
# branch-a version
def split_data(df, test_size=0.25, random_state=42): # changed to 0.25
git add src/preprocess.py
git commit -m "Change test split size to 25% for larger test set"
git switch main
git switch -c branch-b
Change the same line, differently:
# branch-b version
def split_data(df, test_size=0.15, random_state=42): # changed to 0.15
git add src/preprocess.py
git commit -m "Reduce test split size to 15% for more training data"
git switch main
# branch-a merges cleanly
git merge branch-a
# branch-b conflicts
git merge branch-b
Output
Auto-merging src/preprocess.py
CONFLICT (content): Merge conflict in src/preprocess.py
Automatic merge failed; fix conflicts and then commit the result.
Step 9.2 — resolve it
git status
Output
On branch main
You have unmerged paths.
(fix conflicts and run "git commit")
Unmerged paths:
(use "git add <file>..." to mark resolution)
both modified: src/preprocess.py
Open src/preprocess.py. The conflicted section:
<<<<<<< HEAD
def split_data(df, test_size=0.25, random_state=42):
=======
def split_data(df, test_size=0.15, random_state=42):
>>>>>>> branch-b
You talk it over with your teammate and land on 0.20, the original default:
def split_data(df, test_size=0.20, random_state=42):
Delete the <<<<<<<, =======, and >>>>>>> markers entirely, then:
git add src/preprocess.py
git commit -m "Resolve merge conflict: keep default test_size=0.20"
# clean up
git branch -d branch-a
git branch -d branch-b
Act 10 · advanced
Reset, to undo local mistakes
git log --oneline -5
Output
d3e4f5a (HEAD -> main) WIP: bad experiment attempt 3
c2b3d4e WIP: bad experiment attempt 2
a1b2c3d WIP: bad experiment attempt 1
f1e2d3c Resolve merge conflict: keep default test_size=0.20
You want to go back to f1e2d3c, right before the three bad commits, and throw them away entirely:
# permanently removes those 3 commits and their changes
git reset --hard f1e2d3c
Output
HEAD is now at f1e2d3c Resolve merge conflict: keep default test_size=0.20
Those three commits are gone, and your code is back to that exact state.
Only do this when the commits haven't been pushed. If they have, use
git revertinstead.
The final history
After all ten acts, the project's history looks like this:
git log --oneline --graph --all
Notice that tag: v1.0 sits on e4d5c6b, the exact commit that was HEAD back in Act 8 when the tag was created, not on the current tip. Tags are fixed pointers. They mark one commit permanently and don't drift as main keeps advancing, unlike branches, which always slide forward to the latest commit. That's exactly what makes them useful for releases: v1.0 will always mean "the SVM release," no matter how many commits pile up on main afterward.
This log tells the whole story: what got built, what broke, what got fixed, and what got tried along the way, including Act 9's conflict (both a7c8d9e and d4e5f6a show up, joined by a real conflict-resolution commit) and Act 10's three discarded commits, which vanished from history entirely once git reset --hard ran.
The workflow, distilled
This is the loop you'll repeat on pretty much every ML project from here on:
Start of every work session:
─────────────────────────────
git switch main
git pull origin main
Starting new work:
─────────────────────────────
git switch -c feature/your-feature-name
During work (repeat many times):
─────────────────────────────
[edit files]
git status
git diff
git add <specific files>
git commit -m "Clear description of what and why"
Keeping your branch updated:
─────────────────────────────
git fetch origin
git rebase origin/main
Finishing:
─────────────────────────────
git push -u origin feature/your-feature-name
[open a pull request on GitHub]
[merge after review]
After merge:
─────────────────────────────
git switch main
git pull origin main
git branch -d feature/your-feature-name
Emergency — discard all local changes:
─────────────────────────────
git stash ← save first, if you might want it back
git restore . ← the nuclear option
Emergency — undo a commit that's already pushed:
─────────────────────────────
git revert HEAD
git push
What to do next
You've now finished the Git & GitHub mini-series, Articles 1 and 2. A few concrete next steps: create a GitHub repo for your next ML project today, practice the feature-branch workflow on something you're already working on, and write a real .gitignore for every project from here on, not just this one.
Worth learning next: DVC for versioning data files and model artifacts alongside Git (covered in the next article in this series), GitHub Actions for running tests and deployments automatically on every push, pre-commit hooks to run formatters and linters before every commit, and Conventional Commits if you're on a larger team and want a structured commit message format.
A few portfolio habits worth building now: pin three to five ML projects on your GitHub profile, give each one a README with actual results and instructions for running it, use tags to mark working versions, and clear notebook cell output before committing, so diffs stay readable.
You're equipped to manage ML projects properly now. Every commit is a small, honest record of what you did and why, and that record is worth keeping clean.