MLOps for ML practitioners · Article 1

Git & GitHub: the complete guide for ML practitioners

Why Git exists, how it actually works, and every command you'll use day to day, explained for people who came from notebooks, not software engineering.

Part of the MLOps series · read the introduction first if you haven't · next up: Article 2, a hands-on Git project

New to development? This article assumes you can open a terminal, run basic commands, and have Git installed. If that feels shaky, the companion prerequisites series covers exactly this ground first, particularly the pieces on the terminal and on installing Git, Python, and VS Code.

This is the first article in a two-part series. This one teaches you Git and GitHub end to end: every concept, every command, every workflow you'll actually use. The next one takes a small ML project and runs it through the same commands for practice.


Part 1Why Git? The problem it solves

The nightmare without version control

Imagine you're building a machine learning model to predict customer churn. Over the course of two weeks, your project folder ends up looking like this:

churn_model/
├── model.py
├── model_v2.py
├── model_v2_final.py
├── model_v2_final_ACTUALLY_FINAL.py
├── model_backup_before_i_broke_it.py
├── model_johns_changes.py
├── model_johns_changes_merged_with_mine.py

This isn't hypothetical. It's what every developer and data scientist does before learning Git. Now think about what happens when you break something that was working yesterday and can't remember what you changed, when a teammate edits the same file you did and you both overwrite each other's work, when your manager asks whether you can roll back to the version from last Tuesday that the client approved, when you want to try a different algorithm without breaking your working code, or when you want to show your work publicly so recruiters or collaborators can see it.

Every one of those is painful without version control. Git solves all of them cleanly.

What Git actually does

Git is a distributed version control system. Version control means Git tracks every change you make to your files over time: who changed what, when, and why. It's a bit like Google Docs version history, but for an entire codebase and with much more precision. Distributed means every developer has the complete history of the project on their own machine. There's no single point of failure. Even if the server goes down, your full history is still sitting on your laptop.

Here's what that gets you in concrete terms:

ProblemGit's solution
"I broke something, how do I go back?"Revert to any previous commit
"I want to try something risky"Create a branch, merge only when it works
"My teammate overwrote my code"Merge system handles conflicts explicitly
"What changed since yesterday?"git diff, git log show exact changes
"Can we release v1 while working on v2?"Branches for stable vs. development versions
"I need to share my code with the world"Push to GitHub

Git vs. GitHub: not the same thing

This is the most common point of confusion for beginners. Git is a tool that runs on your computer and tracks changes locally. GitHub is a website that hosts your Git repositories in the cloud.

A useful way to hold the two apart: Git is like Microsoft Word's "Track Changes" feature. GitHub is like Google Drive, a place to store and share the documents. You can use Git without GitHub, purely locally. You can't really use GitHub without Git. In ML work you'll use both together: Git locally to track experiments, GitHub to collaborate, deploy, and share what you've built.


Part 2Core concepts you need before anything else

Four ideas underpin everything else in Git. Don't rush past them.

Repository

A repository (or "repo") is a directory that Git is tracking. It contains all your project files plus a hidden .git folder where Git stores its entire history. Running git init in a folder is what turns it into a repository.

my_ml_project/       ← this is your repository
├── .git/            ← hidden folder, Git's brain. Don't touch it manually
├── train.py
├── model.py
└── requirements.txt

The three zones

This is the single most important concept in Git. Every file you work with lives in one of three zones: your working directory, a staging area, and the local repository.

Working Directory files on disk Staging Area changes to save Local Repository permanent history git add git restore git commit Remote GitHub git push
Fig. 1 — the three zones every file moves through, plus the push to a remote

The working directory is where you edit files normally; Git knows they exist but may or may not be tracking their changes yet. The staging area is a waiting room: you selectively add changes here before committing, which lets you commit only part of your work at a time. The local repository is the permanent history. Once something's committed, it's saved for good unless you explicitly remove it. And the remote repository is the copy of all of this sitting on GitHub.

Commit

A commit is a snapshot of your staged changes, saved to the repository along with a message describing what you did. Think of commits as save points in a video game: you can always return to any of them.

Each commit carries a unique ID (a "SHA hash," something like a3f5c2d), the author's name and email, a timestamp, the message you wrote, and a pointer back to the previous commit, which is what forms the chain.

Branch

A branch is an independent line of development. The default branch is called main (or master in older repos). Creating a new branch gives you a copy of the current state to experiment on without touching the original.

main branch:    A ── B ── C ──────────────── F (merged)
                               \            /
feature branch:                 D ── E ────

In ML work you'll typically use branches like main for stable, production-ready code, something like feature/add-random-forest while you're experimenting with a new model, and fix/memory-leak for a bug fix.


Part 3Installation and first-time setup

Installing Git

macOS:

brew install git
# or just type 'git' in terminal — macOS will prompt you to install Xcode tools

Ubuntu/Debian Linux:

sudo apt update
sudo apt install git

Windows: download from git-scm.com/download/win and run the installer. It also installs Git Bash, a terminal for running Git commands on Windows.

Verify it installed:

git --version
# Output: git version 2.43.0

First-time configuration

Git needs to know who you are before it lets you commit anything. Run these once on any new machine:

# Your name, appears in every commit you make
git config --global user.name "Your Name"

# Your email, use the same one as your GitHub account
git config --global user.email "you@example.com"

# Default branch name — 'main' is the modern standard
git config --global init.defaultBranch main

# VS Code as your default editor (optional but recommended)
git config --global core.editor "code --wait"

# How 'git pull' reconciles diverging branches (avoids a common first-run warning)
git config --global pull.rebase false

--global applies a setting to every repo on the machine. You can also set config per-project by leaving it off, inside that project's folder.

Why the pull.rebase line? The first time you run git pull on a modern Git install, you'll likely see a warning that pulling without specifying how to reconcile divergent branches is discouraged. Setting pull.rebase false tells Git to use a regular merge, the simpler default for beginners, and the warning goes away for good. Once rebasing feels familiar (Part 5), you can switch to pull.rebase true if you'd rather keep a linear history.

View your settings:

git config --list

Connecting to GitHub over SSH

Note: GitHub stopped accepting account passwords for Git operations over HTTPS in August 2021.1 HTTPS now needs a personal access token instead, generated under GitHub's developer settings, which you paste in wherever it asks for a password. SSH sidesteps this entirely: set it up once below and you won't be prompted for credentials again.

# 1. Generate an SSH key pair
ssh-keygen -t ed25519 -C "you@example.com"
# press Enter three times to accept the defaults

# 2. Copy your PUBLIC key
cat ~/.ssh/id_ed25519.pub

# 3. GitHub → Settings → SSH Keys → New SSH Key
# paste what you copied and save

# 4. Test the connection
ssh -T git@github.com
# Output: Hi username! You've successfully authenticated.

Part 4Essential daily commands

Everything in this section is something you'll type most days. Each entry covers what it does, when you'd reach for it, and a real example.

git init

What it does: Turns any folder into a Git repository by creating a hidden .git folder inside it.

When to use it: At the very start of a new project.

mkdir iris_classifier
cd iris_classifier
git init
# Output: Initialized empty Git repository in /iris_classifier/.git/

Never run git init inside an existing Git repo, like your home folder. It creates a "nested repo" and just causes confusion.

git clone

What it does: Downloads a complete copy of a remote repository, including all history, branches, and commits.

When to use it: When you want to work on an existing project that's already on GitHub.

# HTTPS
git clone https://github.com/username/iris_classifier.git

# SSH (preferred once you've set it up)
git clone git@github.com:username/iris_classifier.git

# clone into a specific folder name
git clone git@github.com:username/iris_classifier.git my_project

After cloning, Git sets up a remote called origin automatically, pointing back to GitHub.

git status

What it does: Shows the current state of your working directory and staging area: which files are modified, staged, or untracked.

When to use it: Constantly, before and after almost everything. Treat it as your GPS.

git status
On branch main
Your branch is up to date with 'origin/main'.

Changes not staged for commit:      ← modified but not yet staged
  (use "git add <file>..." to update what will be committed)
        modified:   model.py

Untracked files:                    ← new files Git has never seen
  (use "git add <file>..." to include in what will be committed)
        requirements.txt

nothing added to commit but untracked files present

Short form:

git status -s
# M  model.py         (M = modified)
# ?? requirements.txt  (?? = untracked)

git add

What it does: Moves changes from the working directory into the staging area, ready for the next commit.

When to use it: After editing files, before committing. You decide exactly what goes into each commit.

# stage a single file
git add model.py

# stage several specific files
git add model.py train.py

# stage every change in the current directory (use with care)
git add .

# stage every .py file
git add *.py

# stage part of a file interactively (lets you commit half a file)
git add -p model.py

One habit worth building early: don't reach for git add . on autopilot in ML projects. It's an easy way to accidentally stage a large data file or a set of trained model weights. Check git status first, or lean on a proper .gitignore.

git commit

What it does: Saves a permanent snapshot of everything currently staged, along with a message.

When to use it: Once you've staged the changes you want. Commit often; small, focused commits beat giant ones.

# standard commit with an inline message
git commit -m "Add logistic regression baseline model"

# stage every modified TRACKED file and commit in one step
# (skips staging for files Git already knows about)
git commit -am "Fix accuracy calculation bug"

# open your editor for a longer message
git commit

Good commit messages matter more than most beginners assume:

# weak messages
git commit -m "fix"
git commit -m "changes"
git commit -m "asdfgh"

# better — present tense, imperative mood
git commit -m "Add random forest classifier with 5-fold cross-validation"
git commit -m "Fix data leakage in train-test split"
git commit -m "Remove hardcoded file paths, use config instead"

A good message completes the sentence: "If applied, this commit will ___."

git log

What it does: Shows the commit history on the current branch.

When to use it: To understand what's been done, find a specific commit, or trace when a bug crept in.

# full, verbose log
git log

# compact, one line per commit — most useful day to day
git log --oneline

# last 5 commits
git log --oneline -5

# a visual graph of all branches
git log --oneline --graph --all

# what files changed in each commit
git log --stat

# search commit messages by keyword
git log --grep="accuracy"

# commits by one author
git log --author="Jane"

git log --oneline --graph --all looks something like this:

* a3f5c2d (HEAD -> main) Add evaluation metrics
* b1e2d4f Fix preprocessing bug
| * c9d3a11 (feature/random-forest) Add random forest model
|/
* 8a7b3e2 Initial project structure

git diff

What it does: Shows the line-by-line differences between file versions.

When to use it: Before staging or committing, to review exactly what changed.

# working directory vs. last commit (unstaged changes)
git diff

# staging area vs. last commit (staged changes) — identical to git diff --cached
git diff --staged

# between two commits
git diff a3f5c2d b1e2d4f

# between two branches
git diff main feature/random-forest

# one file only
git diff model.py

Reading diff output:

- accuracy = correct / total        ← removed
+ accuracy = correct / len(y_test)  ← added

git push

What it does: Sends your local commits to the remote repository on GitHub.

When to use it: After committing locally, whenever you want to share the work or back it up.

# push the current branch to its remote counterpart
git push

# first push of a new branch
git push -u origin feature/random-forest
# -u sets the upstream — future 'git push' on this branch needs no arguments

# push to a specific remote and branch
git push origin main

# force push (dangerous — rewrites remote history)
git push --force

git push --force on a shared branch like main can destroy a teammate's work. Keep it to your own private branches.

git pull

What it does: Fetches new commits from the remote and merges them into your current branch. Effectively git fetch + git merge in one step.

When to use it: At the start of each work session, or whenever someone tells you they pushed new code.

git pull
git pull origin main

# rebase instead of merge (cleaner history, once you're comfortable with rebase)
git pull --rebase origin main

git fetch

What it does: Downloads new commits and branches from the remote without touching your local working files. A safe way to look at what's changed before merging it in.

When to use it: When you want to see what others have pushed without immediately merging it.

git fetch origin
git diff main origin/main
git merge origin/main   # when you're ready

pull is automatic. fetch lets you inspect first. Fine to default to pull as a beginner; on a team, fetch gives you more control.


Part 5Branching and merging

This is where Git's real power shows up. It matters most on ML projects, where you're often experimenting with different models, hyperparameters, or preprocessing approaches at the same time.

Creating and switching branches

# list local branches (* marks the current one)
git branch

# include remote branches too
git branch -a

# create a branch without switching to it
git branch feature/add-svm-model

# switch to an existing branch
git switch feature/add-svm-model
# (older syntax: git checkout feature/add-svm-model)

# create AND switch in one step — the common case
git switch -c feature/add-svm-model
# (older syntax: git checkout -b feature/add-svm-model)

# delete a branch, only once it's merged
git branch -d feature/add-svm-model

# force-delete an unmerged branch (permanent)
git branch -D feature/add-svm-model

Merging branches

What it does: Folds the changes from one branch into another.

# switch to the branch you want to merge INTO
git switch main

# merge the feature branch into it
git merge feature/add-svm-model
Fast-forward merge A B C main has no new commits, so it just catches up A B C D (feature caught up) Three-way merge A B E M (merge) C D
Fig. 2 — a fast-forward merge just moves the pointer; a three-way merge creates a dedicated merge commit joining both histories

A fast-forward merge is the clean case: no merge commit is created, main simply catches up. A three-way merge happens once both branches have new commits, so Git creates a dedicated merge commit joining the two histories together.

Merge conflicts: don't panic

A merge conflict happens when two branches changed the same line of the same file in different ways. Git can't decide which version to keep, so it asks you.

git merge feature/new-preprocessing
# CONFLICT (content): Merge conflict in preprocess.py
# Automatic merge failed; fix conflicts and then commit the result.

Open preprocess.py and you'll see conflict markers:

<<<<<<< HEAD                         ← your current branch (main)
scaler = StandardScaler()
=======                              ← divider
scaler = MinMaxScaler()
>>>>>>> feature/new-preprocessing    ← the incoming branch

To resolve it: edit the file down to the version you actually want (delete the markers), then git add preprocess.py, then git commit (Git writes the merge commit message for you).

git rebase — an alternative to merge

What it does: Replays your branch's commits on top of another branch, giving you a linear history instead of a merge commit. It looks like you branched off the latest code all along.

# from your feature branch
git rebase main
Before rebase:   main:    A ── B ── E
                               \
                 feature:       C ── D

After rebase:    main:    A ── B ── E
                                     \
                 feature:             C' ── D'  (replayed on top of E)

Rebase your own feature branches to keep history clean. Never rebase a shared branch like main. It rewrites history and breaks everyone else's work.


Part 6Undoing mistakes

Everyone makes mistakes. Git is built to let you undo almost anything safely, as long as you know which tool fits which situation.

git restore

What it does: Discards changes in the working directory, putting a file back to its last committed state.

# discard changes in one file (permanent — be careful)
git restore model.py

# discard all unstaged changes
git restore .

# unstage a file, moving it back to the working directory
git restore --staged model.py

git restore on the working directory is permanent. Unsaved edits are just gone. Only run it when you're sure.

git restore only touches files Git is already tracking; it won't remove new, untracked files, like a stray debug_output.csv left over from testing. For those, reach for git clean:

# preview what would be deleted — do this first, always
git clean -n

# actually delete untracked files
git clean -f

# also delete untracked folders
git clean -fd

Like git restore, git clean -f is permanent. Run git clean -n first, every time, to see exactly what would go.

git reset

What it does: Moves the current branch's tip back to an earlier commit. It has three modes.

# --soft: uncommit but keep changes staged
git reset --soft HEAD~1

# --mixed (default): uncommit and unstage, keep changes in the working dir
git reset HEAD~1

# --hard: uncommit, unstage, AND discard the changes (dangerous)
git reset --hard HEAD~1

# reset to a specific commit
git reset --hard a3f5c2d

HEAD is the current commit; ~1 means one commit before it, ~3 means three commits back.

ModeCommit undone?Staging cleared?Working dir changed?
--softYesNoNo
--mixedYesYesNo
--hardYesYesYes

git revert

What it does: Creates a new commit that undoes a specific commit's changes. Unlike reset, it doesn't rewrite history, which makes it safe on shared branches.

git revert HEAD
git revert a3f5c2d
git revert HEAD --no-edit   # skip the editor, auto-generate the message

Use reset only on commits that exist purely locally and haven't been pushed. Use revert once a commit is already on GitHub and shared with others.

git stash

What it does: Puts your uncommitted changes on a temporary stack and cleans the working directory, so you can switch context without committing half-finished work.

git stash push -m "WIP: experimenting with gradient boosting"

git stash list
# stash@{0}: On main: WIP: experimenting with gradient boosting
# stash@{1}: On main: trying new feature engineering

git stash apply       # apply the most recent, keep it in the list
git stash pop          # apply AND remove from the list
git stash apply stash@{1}
git stash drop stash@{0}
git stash clear

A real scenario: you're mid-way through rewriting your feature engineering code when your manager flags a production bug that needs fixing now. You stash the half-done work, switch to main, fix the bug, commit and push, then come back and git stash pop to pick up where you left off.

git cherry-pick

What it does: Applies one specific commit from any branch onto your current branch.

git cherry-pick a3f5c2d

Say you fixed a data normalization bug on a feature branch. Rather than merging the whole branch, you can cherry-pick just that one fix onto main.


Part 7Working with GitHub

Creating a new GitHub repository

  1. On github.com, click "New repository."
  2. Name it, e.g. iris-classifier.
  3. Set it to public or private.
  4. Skip the README if you already have local code, to avoid a conflict on first push.
  5. Click "Create repository."

Then connect your local repo:

# add GitHub as the remote, named 'origin'
git remote add origin git@github.com:username/iris-classifier.git

git remote -v
# origin  git@github.com:username/iris-classifier.git (fetch)
# origin  git@github.com:username/iris-classifier.git (push)

# push local code to GitHub for the first time
git push -u origin main

Forking a repository

A fork is your own personal copy of someone else's repository, living under your account with full control. It's how open-source collaboration usually works.

  1. On GitHub, open the repo you want to contribute to and click "Fork."
  2. Clone your fork locally: git clone git@github.com:YOUR_USERNAME/original-repo.git
  3. Add the original as an "upstream" remote so you can pull future updates: git remote add upstream git@github.com:ORIGINAL_OWNER/original-repo.git
  4. Sync your fork when needed: git fetch upstream then git merge upstream/main

Pull requests

A pull request is a proposal to merge your branch or fork into another branch. It's the standard way to contribute code, on a team or in open source.

Step 1 — branch, commit, push:

git switch -c feature/add-xgboost-model

git add .
git commit -m "Add XGBoost model with GridSearchCV tuning"

git push -u origin feature/add-xgboost-model

Step 2 — open the PR: GitHub will show a prompt on the repo page, "Compare & pull request." Click it, write a clear title and description, and request reviewers if you're on a team.

Step 3 — after approval: click "Merge pull request."

Tags: marking releases

Tags mark specific commits as milestones, like v1.0 or v2.0-beta. There are two styles; pick one per tag, since running both on the same name back to back will error out (the tag already exists after the first).

# Option A: lightweight — just a name
git tag v1.0

# Option B: annotated — includes a message, author, and date (preferred)
git tag -a v1.0 -m "First stable model release"
git tag                          # list all tags
git push origin v1.0             # tags aren't pushed by default with plain 'git push'
git push --tags                  # push all of them at once
git show v1.0                    # view tag details
git tag -d v1.0                  # delete locally
git push origin --delete v1.0    # delete from GitHub too

If you ever run git checkout v1.0, checking out a tag directly rather than a branch, Git will warn you that you're in a "detached HEAD" state. That just means you're viewing that exact snapshot rather than sitting on a branch, which is normal. To get back to normal, switch to a branch: git switch main.


Part 8.gitignore for ML projects

A .gitignore file tells Git which files to never track. For ML projects specifically, this matters because data files can run into gigabytes, trained models can be hundreds of megabytes, credentials should never be committed at all, and temporary files just clutter your history.

# Python
__pycache__/
*.py[cod]
*.pyo
*.pyd
.Python
env/
venv/
.venv/
*.egg-info/
dist/
build/

# Jupyter notebooks
.ipynb_checkpoints/
*.ipynb_checkpoints

# Data files (track with DVC or store in S3 instead)
data/raw/
data/processed/
*.csv
*.tsv
*.json
*.parquet
*.feather
*.h5
*.hdf5
*.pkl
*.pickle
*.npy
*.npz

# Trained models (can be large)
models/
*.pt
*.pth
*.onnx
*.pb
*.joblib
*.model

# Secrets and config — never commit these
.env
.env.local
config/secrets.yaml
*.key
*.pem

# IDE and OS
.vscode/
.idea/
*.DS_Store
Thumbs.db

# MLflow / experiment tracking
mlruns/
mlflow.db

# Logs and outputs
logs/
*.log
outputs/

A blanket pattern can bite you. Rules like *.csv and *.json ignore every file with that extension anywhere in the project, including a small sample CSV you actually want to track, or a metrics.json file you want to commit (which the Git + DVC article later in this series does). Two fixes: scope the rule to a folder instead of the whole project (data/raw/*.csv rather than *.csv), or add a negation line like !metrics.json to re-include one file even though the broader pattern excludes it.

What you do commit: code, config files without secrets, requirements.txt, a README, small sample data for testing, and notebooks, though large notebooks are debated.


Part 9Collaboration workflows

The feature branch workflow

This is the standard pattern on most ML teams: main stays stable and deployable, and every piece of work, a feature, a fix, an experiment, gets its own branch.

main  ←── always stable, deployable code
 │
 ├── feature/data-augmentation
 ├── feature/hyperparameter-tuning
 ├── fix/fix-class-imbalance
 └── experiment/vision-transformer
Pulllatest main Createfeature branch Commityour work Push &open a PR Review &merge Delete branch,start again
Fig. 3 — the day-to-day loop most ML teams run, over and over, per feature

Day-to-day workflow

# 1. always start by pulling the latest main
git switch main
git pull origin main

# 2. create a feature branch
git switch -c feature/add-bert-embeddings

# 3. work on your feature across multiple commits
git add embeddings.py
git commit -m "Add BERT tokenizer integration"

git add train.py
git commit -m "Update training loop for embedding inputs"

# 4. keep your branch current as others merge code
git fetch origin
git rebase origin/main

# 5. push and open a pull request
git push -u origin feature/add-bert-embeddings
# then open the PR on GitHub

Command reference

CommandWhat it does
git initInitialize a new repository
git clone <url>Download a remote repository
git statusShow working dir & staging area state
git add <file>Stage a file
git add .Stage all changes
git commit -m "msg"Commit staged changes
git log --onelineCompact commit history
git diffShow unstaged changes
git diff --stagedShow staged changes
git pushUpload commits to GitHub
git pullDownload & merge remote changes
git fetchDownload remote changes, no merge
git branchList branches
git switch -c <name>Create & switch to a new branch
git switch <name>Switch to an existing branch
git merge <branch>Merge a branch into the current one
git rebase <branch>Rebase onto another branch
git restore <file>Discard working directory changes
git restore --staged <file>Unstage a file
git reset --soft HEAD~1Undo last commit, keep staged
git reset --hard HEAD~1Undo last commit, discard changes
git revert HEADSafely undo last commit (new commit)
git stashSave unfinished work temporarily
git stash popRestore stashed work
git cherry-pick <sha>Copy one commit to the current branch
git tag -a v1.0 -m "msg"Create an annotated tag
git remote add origin <url>Link the local repo to GitHub
git remote -vView remote URLs

Summary

You now know why Git exists and what specific problems it solves for ML work, the three zones that everything else builds on, and the full set of daily commands with real examples behind them. You can branch and merge for parallel experimentation, undo mistakes safely with restore, reset, revert, and stash, and use GitHub for remotes, pull requests, forks, and tags. You've got a .gitignore that actually fits an ML project, and a feature-branch workflow you can use on a real team starting today.

Article 2 puts all of this into practice on a real ML classification project, running through every command in context.