MLOps for ML practitioners · Article 2

Git in action: the Iris classification project

Article 1 taught every command on its own. This one runs all of them, in order, on a real ML project, the way you'd actually use them on a job.

Part of the MLOps series · read Article 1 first if you haven't · next up: Git + DVC

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
1Init 2Baseline 3Branch 4Merge 5Bug & revert 6Stash 7GitHub & PR 8Tag v1.0 9Conflict 10Reset Green = building forward · amber = something went wrong, and we fix it
Fig. 1 — the ten acts ahead, in order

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

ScenarioThe baseline logistic regression lands at 93% accuracy on the test set.1 You want to try a Random Forest to see if it does better, and you want to do that on a feature branch so 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

ScenarioA junior teammate, or you on a bad day, accidentally drops 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

ScenarioYou're halfway through an SVM experiment when your manager pings you that the preprocessing pipeline is failing in production, right now. Your SVM code is half-written, and you can't commit unfinished 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

  1. On github.com, click "New repository."
  2. Name it iris-classifier.
  3. Make it public, so it's visible on your portfolio.
  4. Skip the README, since you already have one locally.
  5. 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

ScenarioYou and a teammate both edit 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

ScenarioYou made three commits locally that turn out to be completely wrong. None of them have been pushed, so it's safe to reset them away entirely.
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 revert instead.


The final history

After all ten acts, the project's history looks like this:

git log --oneline --graph --all
4a1b2c3 .gitignore 7b2e4c1 README 9d3f1a2 project structure 1d2e6c5 preprocess.py 3f8b7a4 baseline model e5c1d23 evaluate.py 7c4d2e5 random forest 9a8b3c1 evaluate CLI arg b3c2d1e bug (missing stratify) 4d3e2c1 revert the bug b0a1f2e hotfix: return copies v1.0 e4d5c6b merge: SVM branch c7b8a9d SVM classifier f1e2d3c (HEAD) merge conflict resolved a7c8d9e branch-a: 25% d4e5f6a branch-b: 15%
Fig. 2 — the final commit graph, main running bottom to top, with the SVM and conflict branches merging back in

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.