Learning Objectives
By the end of this module, you will be able to:
- Manage multiple remotes (origin, upstream, and custom) for complex collaboration workflows
- Understand upstream tracking branches, divergence detection, and how
git fetchworks behind the scenes - Use
git push --force-with-leasefor safe force pushes after rebase - Clean up stale remote-tracking branches with
git fetch --prune - Create shallow clones, partial clones, and sparse checkouts for large repositories
- Add, update, sync, and remove Git submodules
- Use Git subtrees as an alternative to submodules
- Configure Git LFS (Large File Storage) for binary assets
1. Understanding Remotes in Depth
A remote is a named reference to another copy of your repository — usually hosted on GitHub, GitLab, Bitbucket, or even a bare repository on a local filesystem. When you clone a repository, Git automatically creates a remote called origin pointing to the URL you cloned from.
Bare Repositories: What Remotes Actually Are
Remote servers typically store bare repositories — repositories without a working directory. They contain only Git's database (the contents of .git/), because the server never needs to edit files directly:
# Create a bare repository (what GitHub stores)
git init --bare my-project.git
# Its contents look like the inside of .git/
ls my-project.git/
# HEAD branches/ config description hooks/ info/ objects/ refs/The .git extension on bare repositories is a convention — GitHub URLs like git@github.com:user/repo.git point to bare repositories.
Git's Transfer Protocols
Git supports multiple protocols for communicating with remotes:
| Protocol | URL Format | Authentication | Use Case |
|---|---|---|---|
| SSH | git@github.com:user/repo.git | SSH keys | Most common for push access |
| HTTPS | https://github.com/user/repo.git | Username/password or token | Works through firewalls |
| Git | git://github.com/repo.git | None (read-only) | Public read-only access |
| File | /path/to/repo.git | Filesystem permissions | Local testing, shared drives |
# Clone using the file protocol (great for local testing)
git init --bare /tmp/remote-repo.git
git clone /tmp/remote-repo.git /tmp/local-repoViewing Remote Details
# List remotes (short form)
git remote
# origin
# List remotes with URLs (verbose)
git remote -v
# origin git@github.com:user/repo.git (fetch)
# origin git@github.com:user/repo.git (push)
# Show full details for a remote
git remote show origin
# * remote origin
# Fetch URL: git@github.com:user/repo.git
# Push URL: git@github.com:user/repo.git
# HEAD branch: main
# Remote branches:
# main tracked
# develop tracked
# Local branch configured for 'git pull':
# main merges with remote main
# Local ref configured for 'git push':
# main pushes to main (up to date)Notice that git remote -v shows two URLs per remote — one for fetch and one for push. In complex topologies, these can differ: you might fetch from a read-only mirror but push to your own fork.
2. Managing Multiple Remotes
The Fork Workflow: Origin and Upstream
When you fork a repository on GitHub and clone your fork, you get one remote (origin) pointing to your fork. To stay in sync with the original project, you add a second remote called upstream:
┌──────────────────────────────────┐
│ Original Repository (upstream) │
│ github.com/org/project.git │
└──────────────┬───────────────────┘
│ fork
▼
┌──────────────────────────────────┐
│ Your Fork (origin) │
│ github.com/you/project.git │
└──────────────┬───────────────────┘
│ clone
▼
┌──────────────────────────────────┐
│ Local Repository │
│ ~/code/project/ │
│ │
│ origin → your fork │
│ upstream → original repo │
└──────────────────────────────────┘
# After cloning your fork
git clone git@github.com:you/project.git
cd project
# Add the original repository as "upstream"
git remote add upstream git@github.com:org/project.git
# Verify both remotes
git remote -v
# origin git@github.com:you/project.git (fetch)
# origin git@github.com:you/project.git (push)
# upstream git@github.com:org/project.git (fetch)
# upstream git@github.com:org/project.git (push)Syncing Your Fork with Upstream
# Fetch the latest from upstream
git fetch upstream
# Merge upstream's main into your local main
git checkout main
git merge upstream/main
# Push the updated main to your fork
git push origin mainOr, for a cleaner history:
# Rebase your main on top of upstream's main
git checkout main
git rebase upstream/main
git push origin mainAdding Custom Remotes
You're not limited to two remotes. Common scenarios:
# Add a colleague's fork for cross-review
git remote add alice git@github.com:alice/project.git
# Add a deployment target
git remote add staging ssh://deploy@staging.example.com/var/git/project.git
# Add a mirror
git remote add mirror https://gitlab.com/org/project.git
# Fetch from a specific remote
git fetch alice
# Check out a branch from a colleague's fork
git checkout -b alice-feature alice/new-featureRenaming and Removing Remotes
# Rename a remote
git remote rename origin github
# Remove a remote
git remote remove staging
# Change a remote's URL
git remote set-url origin git@github.com:you/new-repo.git
# Change only the push URL (fetch from one place, push to another)
git remote set-url --push origin git@github.com:you/fork.git3. Upstream Tracking and Divergence
What Are Tracking Branches?
When you run git checkout -b feature origin/feature or git push -u origin feature, Git sets up a tracking relationship between your local branch and a remote-tracking branch. This enables:
git pullandgit pushwithout specifying remote/branchgit statusshowing "Your branch is ahead/behind/diverged"
Remote Local
origin/main ◄────────── main (tracks origin/main)
origin/feature ◄─────── feature (tracks origin/feature)
upstream/main (no local tracking branch)
Setting Up and Viewing Tracking
# Set up tracking when pushing a new branch
git push -u origin feature
# equivalent to:
git push --set-upstream origin feature
# Set tracking for an existing branch
git branch --set-upstream-to=origin/feature feature
# View all tracking relationships
git branch -vv
# * feature abc1234 [origin/feature: ahead 2, behind 1] Latest commit msg
# main def5678 [origin/main] Another commit msg
# Remove tracking
git branch --unset-upstream featureUnderstanding Divergence
When your local branch and its upstream have both moved forward, they've diverged:
C ── D (local feature, ahead 2)
╱
A ── B ── E (common ancestor)
╲
F (origin/feature, behind 1 from your perspective)
git status reports this as "Your branch and 'origin/feature' have diverged, and have 2 and 1 different commits each."
To resolve divergence:
# Option 1: Merge (creates a merge commit)
git pull origin feature
# Option 2: Rebase (linear history, requires force push)
git pull --rebase origin feature
git push --force-with-lease origin feature
# Option 3: Fetch + manual decision
git fetch origin
git log --oneline --left-right feature...origin/feature
# < abc1234 Your local commit 1
# < def5678 Your local commit 2
# > 789abcd Their remote commitThe Fetch-Then-Inspect Pattern
Instead of git pull (which is git fetch + git merge combined), experienced developers often separate the operations:
# Step 1: Download changes (safe — never modifies your branches)
git fetch origin
# Step 2: See what changed
git log --oneline main..origin/main
# Step 3: Decide how to integrate
git merge origin/main # or: git rebase origin/mainThis gives you a chance to review incoming changes before they touch your working branch.
4. Safe Force Pushing with --force-with-lease
The Problem with git push --force
After rewriting history (rebase, amend, squash), a normal git push is rejected because the remote's history diverges from yours. git push --force overwrites the remote — but it's dangerous because it can silently destroy commits that others pushed while you were rebasing:
You rebased: A ── B'── C' (your rewritten history)
Meanwhile, someone pushed:
A ── B ── C ── D (remote has D that you don't know about)
git push --force: A ── B'── C' (D is gone!)
The Solution: --force-with-lease
--force-with-lease checks that the remote branch is in the state you expect (i.e., it hasn't moved since your last fetch). If someone else pushed in the meantime, the push is rejected:
# Safe force push — rejects if remote has unexpected commits
git push --force-with-lease origin feature
# If rejected:
# ! [rejected] feature -> feature (stale info)
# This means someone pushed to the remote since your last fetch.
# Fetch, review their changes, rebase again, then retry.
git fetch origin
git rebase origin/feature
git push --force-with-lease origin featureConfiguring as the Default
You can make --force-with-lease the behavior when you use --force:
# Git alias for safe force push
git config --global alias.pushf "push --force-with-lease"
# Or in your shell aliases
alias gpf="git push --force-with-lease"When --force-with-lease Isn't Enough
There's a subtle gotcha: if you run git fetch in the background (some IDEs do this automatically), --force-with-lease thinks you've seen the latest remote state — even if you haven't actually reviewed it. In this case, use the explicit form:
# Only force push if origin/feature is at exactly <expected-hash>
git push --force-with-lease=feature:<expected-hash> origin feature5. Cleaning Up Remote-Tracking Branches
The Stale Branch Problem
When branches are deleted on the remote (e.g., after merging a PR), your local remote-tracking references (origin/feature-x) persist. Over time, they accumulate:
git branch -r
# origin/main
# origin/feature-done ← deleted on remote, still tracked locally
# origin/feature-merged ← deleted on remote, still tracked locally
# origin/feature-active ← still exists on remotePruning Stale References
# Remove stale remote-tracking branches
git fetch --prune
# or equivalently:
git remote prune origin
# Combine fetch + prune (recommended)
git fetch --prune origin
# Automatically prune on every fetch
git config --global fetch.prune trueFull Cleanup Workflow
# 1. Prune stale remote-tracking branches
git fetch --prune
# 2. List local branches that have been merged to main
git branch --merged main
# feature-done
# feature-merged
# * main
# 3. Delete merged local branches (except main/develop)
git branch --merged main | grep -v "main\|develop\|\*" | xargs git branch -d
# 4. Verify clean state
git branch -aListing Branches by Staleness
# Show remote branches sorted by last commit date
git for-each-ref --sort=-committerdate --format='%(committerdate:relative) %(refname:short)' refs/remotes/origin/
# Find branches older than 3 months
git for-each-ref --sort=committerdate --format='%(committerdate:short) %(refname:short)' refs/remotes/origin/ \
| awk '$1 < "2024-01-01"'6. Shallow Clones, Partial Clones, and Sparse Checkout
For large repositories (thousands of commits, gigabytes of data), you don't always need the complete history or every file. Git provides three mechanisms to reduce clone size.
Shallow Clones (--depth)
Download only the most recent commits:
# Clone with only the last commit
git clone --depth 1 https://github.com/torvalds/linux.git
# Clone with the last 10 commits
git clone --depth 10 https://github.com/org/large-repo.git
# Shallow clone a specific branch
git clone --depth 1 --branch v5.0 --single-branch https://github.com/org/repo.gitShallow clones create a shallow boundary — Git stores a special marker indicating where history was truncated:
Full clone: A ── B ── C ── D ── E ── F (HEAD)
Shallow (--depth 1): F (HEAD) ← "grafted" boundary
Limitations of shallow clones:
git logonly shows commits within the depthgit blamemay show incorrect results (can't trace past the boundary)git merge-basemay fail (can't find common ancestors)- Some CI/CD tools require full history
# Deepen a shallow clone incrementally
git fetch --deepen=10
# Convert a shallow clone to a full clone
git fetch --unshallowPartial Clones (--filter)
Partial clones download all commits but skip large blobs (files) until they're actually needed. This is better than shallow clones for development because you get full history:
# Clone without downloading any file content (blobless clone)
git clone --filter=blob:none https://github.com/org/large-repo.git
# Clone without downloading tree objects (treeless clone) — even smaller
git clone --filter=tree:0 https://github.com/org/large-repo.git
# Clone without blobs larger than 1MB
git clone --filter=blob:limit=1m https://github.com/org/large-repo.gitWhen you checkout a file or run git diff, Git lazily fetches the needed blobs on demand. This is excellent for monorepos where you only work on a subset of files.
Sparse Checkout
Sparse checkout tells Git to only materialize specific directories in your working tree. Combined with partial clones, this is powerful for monorepos:
# Clone with sparse checkout
git clone --filter=blob:none --sparse https://github.com/org/monorepo.git
cd monorepo
# By default, only root-level files are checked out
ls
# README.md package.json ...
# Add specific directories to your working tree
git sparse-checkout add packages/my-service
git sparse-checkout add packages/shared-utils
# List what's currently checked out
git sparse-checkout list
# packages/my-service
# packages/shared-utils
# Use "cone" mode (recommended — faster, directory-based patterns)
git sparse-checkout set --cone packages/my-service packages/shared-utils
# Disable sparse checkout (get everything back)
git sparse-checkout disableWhen to Use Each
Need just the latest code for CI? → Shallow clone (--depth 1)
Need full history but repo has huge files → Partial clone (--filter=blob:none)
Need only specific directories? → Sparse checkout
Working on a monorepo daily? → Partial clone + sparse checkout
7. Git Submodules
Submodules let you embed one Git repository inside another as a subdirectory. Each submodule is an independent repository with its own history, pinned to a specific commit.
The Submodule Model
main-project/
├── .git/
├── .gitmodules ← declares submodule URLs
├── src/
│ └── app.js
└── libs/
└── shared-utils/ ← submodule (separate repo, pinned to commit abc123)
├── .git ← points to its own repository
├── src/
└── package.json
Adding a Submodule
# Add a submodule at a specific path
git submodule add https://github.com/org/shared-utils.git libs/shared-utils
# This creates/modifies two files:
# .gitmodules — declares the submodule's URL and path
# The submodule directory is added as a special entry in the indexThe .gitmodules file looks like:
[submodule "libs/shared-utils"]
path = libs/shared-utils
url = https://github.com/org/shared-utils.gitCloning a Project with Submodules
# Option 1: Clone, then initialize submodules
git clone https://github.com/org/main-project.git
cd main-project
git submodule init
git submodule update
# Option 2: Clone everything at once (recommended)
git clone --recurse-submodules https://github.com/org/main-project.git
# Option 3: If you forgot --recurse-submodules
git submodule update --init --recursiveUpdating Submodules
# Update submodule to the commit pinned in the parent repo
git submodule update
# Update submodule to the latest commit on its remote branch
cd libs/shared-utils
git checkout main
git pull
cd ../..
# Or update all submodules to their latest remote commits
git submodule update --remote
# After updating, commit the new pinned commit in the parent repo
git add libs/shared-utils
git commit -m "chore: update shared-utils submodule to latest"Working Inside a Submodule
# Enter the submodule
cd libs/shared-utils
# It's a full repo — you can branch, commit, push
git checkout -b fix/bug-123
echo "fix" >> src/utils.js
git add . && git commit -m "fix: resolve null check"
git push origin fix/bug-123
# Go back to parent and record the new commit
cd ../..
git add libs/shared-utils
git commit -m "chore: point shared-utils to bug fix commit"Syncing and Removing Submodules
# If the submodule's URL changes in .gitmodules
git submodule sync
git submodule update --init --recursive
# Remove a submodule (multi-step process)
git submodule deinit libs/shared-utils # unregister
git rm libs/shared-utils # remove from index and working tree
rm -rf .git/modules/libs/shared-utils # remove cached repo data
git commit -m "chore: remove shared-utils submodule"Common Submodule Gotchas
- Detached HEAD: Submodules default to a detached HEAD state (pinned to a specific commit). Always
git checkout <branch>before making changes inside a submodule. - Forgetting to push the submodule: If you commit changes inside a submodule but forget to push, others get a reference to a commit that doesn't exist on the remote.
- Recursive operations: Most Git commands don't recurse into submodules by default. Use
--recurse-submoduleswithclone,pull,fetch, andpush.
8. Git Subtrees
Git subtrees are an alternative to submodules that embed external repository content directly into your repository's tree. There's no special configuration file, no detached HEAD issues, and no extra initialization steps.
Subtree vs. Submodule
| Aspect | Submodule | Subtree |
|---|---|---|
| Storage | Separate repo, pinned commit | Merged into parent repo |
.gitmodules required | Yes | No |
| Clone complexity | --recurse-submodules needed | Normal git clone |
| Working inside | Separate commits, separate push | Normal commits in parent |
| Extracting changes back | Push from submodule | subtree push (more complex) |
| Repo size | Small (just a pointer) | Larger (contains full code) |
| Best for | Libraries with independent release cycles | Vendoring, mono-ish repos |
Adding a Subtree
# Add an external repo as a subtree
git subtree add --prefix=libs/shared-utils \
https://github.com/org/shared-utils.git main --squash
# --prefix: where to place the code
# --squash: collapse the external repo's history into one commitPulling Updates from the Subtree Source
# Pull latest changes from the external repo
git subtree pull --prefix=libs/shared-utils \
https://github.com/org/shared-utils.git main --squashPushing Changes Back to the Subtree Source
If you've made changes to files in the subtree directory and want to contribute them back:
# Push changes from the subtree back to the external repo
git subtree push --prefix=libs/shared-utils \
https://github.com/org/shared-utils.git feature/my-fixSimplifying with a Remote
# Add a named remote to avoid repeating the URL
git remote add shared-utils https://github.com/org/shared-utils.git
# Now use the remote name
git subtree pull --prefix=libs/shared-utils shared-utils main --squash
git subtree push --prefix=libs/shared-utils shared-utils feature/fix9. Git LFS (Large File Storage)
Git is designed for source code — small text files that diff efficiently. Binary files (images, videos, models, datasets) bloat the repository because Git stores a new full copy for every version. Git LFS solves this by storing large files on a separate server and replacing them with lightweight pointers in the repository.
How LFS Works
Without LFS:
.git/objects/
├── abc123 (5MB PSD file, version 1)
├── def456 (5MB PSD file, version 2)
├── ghi789 (5MB PSD file, version 3)
└── ... (repo grows 5MB per edit!)
With LFS:
.git/objects/
├── abc123 (134-byte pointer to LFS server)
├── def456 (134-byte pointer to LFS server)
└── ghi789 (134-byte pointer to LFS server)
LFS server:
├── abc123 (5MB actual file, version 1)
├── def456 (5MB actual file, version 2)
└── ghi789 (5MB actual file, version 3)
The pointer file looks like:
version https://git-lfs.github.com/spec/v1
oid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393
size 5242880
Installing and Configuring LFS
# Install Git LFS
brew install git-lfs # macOS
apt install git-lfs # Ubuntu/Debian
# or download from https://git-lfs.com
# Initialize LFS in your user config (once per machine)
git lfs install
# Track file patterns
git lfs track "*.psd"
git lfs track "*.zip"
git lfs track "*.mp4"
git lfs track "assets/models/**"
# This creates/updates .gitattributes
cat .gitattributes
# *.psd filter=lfs diff=lfs merge=lfs -text
# *.zip filter=lfs diff=lfs merge=lfs -text
# *.mp4 filter=lfs diff=lfs merge=lfs -text
# assets/models/** filter=lfs diff=lfs merge=lfs -text
# IMPORTANT: Commit .gitattributes before adding LFS files
git add .gitattributes
git commit -m "chore: configure Git LFS tracking"
# Now add and commit LFS files normally
git add design.psd
git commit -m "feat: add hero image design"
git push origin main # LFS files upload automaticallyLFS Operations
# See which files are tracked by LFS
git lfs ls-files
# See LFS tracking patterns
git lfs track
# Check LFS status
git lfs status
# Pull LFS files (if they weren't downloaded)
git lfs pull
# Fetch LFS files for specific branches or refs
git lfs fetch --recent
git lfs fetch origin main
# Migrate existing files to LFS (retroactively)
git lfs migrate import --include="*.psd" --everything
# WARNING: This rewrites history! Coordinate with your team.LFS Considerations
- Hosting support: GitHub, GitLab, Bitbucket, Azure DevOps all support LFS. Self-hosted requires an LFS server.
- Storage limits: GitHub gives 1 GB free LFS storage with 1 GB/month bandwidth. Additional storage is paid.
- Clone behavior:
git clonedownloads LFS pointers; actual files are fetched on checkout. Usegit lfs clonefor parallel downloads. - CI/CD: Ensure your CI runners have
git-lfsinstalled and thatgit lfs pullruns before build steps.
Command Reference
| Command | Description |
|---|---|
git remote -v | List remotes with fetch/push URLs |
git remote add <name> <url> | Add a new remote |
git remote remove <name> | Remove a remote |
git remote rename <old> <new> | Rename a remote |
git remote set-url <name> <url> | Change a remote's URL |
git remote show <name> | Show detailed remote info |
git fetch <remote> | Download objects/refs from a remote |
git fetch --prune | Fetch and remove stale remote-tracking refs |
git fetch --all | Fetch from all remotes |
git push -u <remote> <branch> | Push and set upstream tracking |
git push --force-with-lease | Force push only if remote hasn't changed |
git branch -vv | Show branches with tracking info and ahead/behind |
git branch --set-upstream-to=<remote>/<branch> | Set tracking for current branch |
git clone --depth <n> | Shallow clone with N commits of history |
git fetch --unshallow | Convert shallow clone to full clone |
git clone --filter=blob:none | Partial clone (blobless) |
git clone --sparse | Clone with sparse checkout enabled |
git sparse-checkout add <path> | Add a directory to sparse checkout |
git sparse-checkout set --cone <paths> | Set sparse checkout directories (cone mode) |
git submodule add <url> <path> | Add a submodule |
git submodule update --init --recursive | Initialize and update all submodules |
git submodule update --remote | Update submodules to latest remote commit |
git submodule deinit <path> | Unregister a submodule |
git subtree add --prefix=<dir> <url> <branch> | Add a subtree |
git subtree pull --prefix=<dir> <url> <branch> | Pull updates into a subtree |
git subtree push --prefix=<dir> <url> <branch> | Push subtree changes back |
git lfs install | Initialize Git LFS |
git lfs track "<pattern>" | Track files by pattern with LFS |
git lfs ls-files | List LFS-tracked files |
git lfs pull | Download LFS file content |
Hands-On Lab: Advanced Remote Operations
Setup
mkdir remote-ops-lab && cd remote-ops-labPart 1: Multiple Remotes and the Fork Workflow
Goal: Simulate the fork workflow with local bare repositories.
# 1. Create the "upstream" bare repository (simulating the original project)
git init --bare /tmp/upstream-project.git
cd /tmp/upstream-project.git
# We need some initial content — clone it temporarily to seed it
git clone /tmp/upstream-project.git /tmp/seed-repo
cd /tmp/seed-repo
echo "# Shared Project" > README.md
echo "console.log('hello');" > app.js
git add . && git commit -m "initial commit"
git push origin main
cd /tmp && rm -rf seed-repo
# 2. Create "your fork" bare repository
git clone --bare /tmp/upstream-project.git /tmp/your-fork.git
# 3. Clone your fork as your local working copy
cd ~/remote-ops-lab
git clone /tmp/your-fork.git my-project
cd my-project
# 4. Add upstream remote
git remote add upstream /tmp/upstream-project.git
# 5. Verify both remotes
git remote -v
# origin /tmp/your-fork.git (fetch)
# origin /tmp/your-fork.git (push)
# upstream /tmp/upstream-project.git (fetch)
# upstream /tmp/upstream-project.git (push)
# 6. Simulate upstream getting new commits
git clone /tmp/upstream-project.git /tmp/upstream-dev
cd /tmp/upstream-dev
echo "console.log('new upstream feature');" >> app.js
git add . && git commit -m "feat: add new feature upstream"
git push origin main
cd ~/remote-ops-lab/my-project
# 7. Sync your fork with upstream
git fetch upstream
git log --oneline main..upstream/main # See what's new
git merge upstream/main
git push origin main # Push synced changes to your forkCheckpoint: git log --oneline shows both the initial commit and the upstream feature commit. git remote -v shows both origin and upstream.
Part 2: Force Push with --force-with-lease
Goal: Understand why --force-with-lease is safer than --force.
# 1. Create a feature branch and push it
git checkout -b feature/rewrite-history
echo "version 1" > feature.txt
git add . && git commit -m "feat: version 1"
echo "version 2" > feature.txt
git add . && git commit -m "feat: version 2"
git push -u origin feature/rewrite-history
# 2. Rewrite history with interactive rebase (squash the two commits)
git rebase -i HEAD~2
# Change the second commit's "pick" to "squash", save, write a combined message
# 3. Try a normal push (this will fail)
git push origin feature/rewrite-history
# ! [rejected] — remote contains work that you do not have locally
# 4. Use --force-with-lease (safe force push)
git push --force-with-lease origin feature/rewrite-history
# 5. Simulate the danger: someone else pushes to your branch
# (In another terminal or by using the bare repo directly)
cd /tmp
git clone /tmp/your-fork.git /tmp/colleague-clone
cd /tmp/colleague-clone
git checkout feature/rewrite-history
echo "colleague's work" >> feature.txt
git add . && git commit -m "feat: colleague's contribution"
git push origin feature/rewrite-history
# 6. Back in your repo, try --force-with-lease without fetching first
cd ~/remote-ops-lab/my-project
echo "my new change" >> feature.txt
git add . && git commit --amend --no-edit
git push --force-with-lease origin feature/rewrite-history
# ! [rejected] — stale info (this PROTECTED your colleague's commit!)Checkpoint: The --force-with-lease push was rejected, preventing you from overwriting your colleague's commit. A --force would have silently destroyed it.
Part 3: Shallow and Partial Clones
Goal: Experience the size differences between clone types.
cd ~/remote-ops-lab
# 1. Full clone
git clone https://github.com/git/git.git git-full 2>&1 | tail -3
du -sh git-full/.git
# 2. Shallow clone (depth 1)
git clone --depth 1 https://github.com/git/git.git git-shallow 2>&1 | tail -3
du -sh git-shallow/.git
# 3. Compare sizes
echo "Full clone .git size:"
du -sh git-full/.git
echo "Shallow clone .git size:"
du -sh git-shallow/.git
# 4. Compare available history
echo "Full clone history:"
git -C git-full log --oneline | wc -l
echo "Shallow clone history:"
git -C git-shallow log --oneline | wc -l
# 5. Deepen the shallow clone
cd git-shallow
git fetch --deepen=100
git log --oneline | wc -l # Now has ~101 commitsCheckpoint: The shallow clone's .git directory should be dramatically smaller than the full clone.
Part 4: Sparse Checkout
Goal: Check out only specific directories from a repository.
cd ~/remote-ops-lab
# 1. Clone with sparse checkout
git clone --filter=blob:none --sparse https://github.com/git/git.git git-sparse
cd git-sparse
# 2. See what's checked out (only root files)
ls
# Should show only root-level files like README.md, Makefile, etc.
# 3. Add specific directories
git sparse-checkout set --cone Documentation t
# 4. Verify
ls Documentation/ # Should now have docs
ls t/ # Should now have tests
# 5. Check that other directories are NOT present
ls contrib/ 2>/dev/null || echo "contrib/ not checked out (expected)"Checkpoint: Only Documentation/ and t/ directories are materialized. The full history is available via git log.
Part 5: Git Submodules
Goal: Add, update, and remove a submodule.
cd ~/remote-ops-lab
# 1. Create a "library" repo
git init --bare /tmp/my-library.git
git clone /tmp/my-library.git /tmp/lib-setup
cd /tmp/lib-setup
echo "module.exports = { version: '1.0.0' };" > index.js
git add . && git commit -m "initial: library v1.0.0"
git push origin main
cd ~/remote-ops-lab
# 2. Create a project that uses the library as a submodule
mkdir submodule-project && cd submodule-project
git init
echo "# My Project" > README.md
git add . && git commit -m "initial commit"
# 3. Add the submodule
git submodule add /tmp/my-library.git libs/my-library
git commit -m "chore: add my-library submodule"
# 4. Inspect what was created
cat .gitmodules
ls libs/my-library/
# 5. Simulate updating the library
cd /tmp/lib-setup
echo "module.exports = { version: '2.0.0' };" > index.js
git add . && git commit -m "feat: bump to v2.0.0"
git push origin main
# 6. Update the submodule in your project
cd ~/remote-ops-lab/submodule-project
git submodule update --remote libs/my-library
cat libs/my-library/index.js # Should show version 2.0.0
git add libs/my-library
git commit -m "chore: update my-library to v2.0.0"
# 7. Simulate cloning the project fresh (with submodules)
cd ~/remote-ops-lab
git clone --recurse-submodules submodule-project submodule-project-clone
cat submodule-project-clone/libs/my-library/index.js # Should show v2.0.0
# 8. Remove the submodule
cd submodule-project
git submodule deinit libs/my-library
git rm libs/my-library
rm -rf .git/modules/libs/my-library
git commit -m "chore: remove my-library submodule"Checkpoint: After step 6, the submodule is at version 2.0.0. After step 8, the submodule is fully removed.
Part 6: Git LFS
Goal: Configure LFS tracking and observe how it stores files.
cd ~/remote-ops-lab
mkdir lfs-project && cd lfs-project
git init
# 1. Initialize LFS
git lfs install
# 2. Track binary file patterns
git lfs track "*.png"
git lfs track "*.zip"
# 3. Commit the tracking config
git add .gitattributes
git commit -m "chore: configure Git LFS tracking"
# 4. Inspect .gitattributes
cat .gitattributes
# 5. Create a binary file and commit it
dd if=/dev/urandom of=image.png bs=1024 count=100 # 100KB random "image"
git add image.png
git commit -m "feat: add hero image"
# 6. Verify it's tracked by LFS
git lfs ls-files
# Should show image.png with its OID
# 7. Inspect what Git actually stored (a pointer, not the full file)
git show HEAD:image.png | head -3
# version https://git-lfs.github.com/spec/v1
# oid sha256:...
# size 102400
# 8. Check LFS status
git lfs statusCheckpoint: git lfs ls-files shows image.png. git show HEAD:image.png shows a small LFS pointer rather than binary data.
Challenge: Multi-Remote Release Workflow
Build a workflow with three remotes:
- Create a development bare repo, a staging bare repo, and a production bare repo
- Clone the development repo as your local working copy
- Add
stagingandproductionas additional remotes - Implement a feature on a branch, merge to
main, push todevelopment - Push
maintostagingfor testing - After "verification," push
maintoproductionand tag it asv1.0.0 - Verify the same commit and tag appear on all three remotes
Hint: Use git push staging main and git push production main --follow-tags to promote across environments.
Common Pitfalls
| Pitfall | Why It Happens | How to Avoid It |
|---|---|---|
Using --force instead of --force-with-lease | Habit or unfamiliarity | Set up a pushf alias for --force-with-lease |
| Stale remote-tracking branches | Branches deleted on remote but not locally | git config --global fetch.prune true |
| Submodule stuck in detached HEAD | Submodules default to detached HEAD | git checkout <branch> inside submodule before editing |
Forgetting --recurse-submodules on clone | Submodule directories appear empty | Use git submodule update --init --recursive |
| Pushing submodule parent before submodule | Others get a reference to a non-existent commit | Push submodule first, then parent. Or: git push --recurse-submodules=on-demand |
Shallow clone breaking git blame | History truncated at graft boundary | Use --filter=blob:none instead of --depth for dev work |
| LFS files not downloading on clone | git-lfs not installed on the machine | Ensure git lfs install ran and git lfs pull after clone |
| Tracking LFS after files already committed | Files already stored as regular Git objects | Use git lfs migrate import to retroactively move them (rewrites history!) |
Forgetting git fetch before inspecting remote changes | Working with stale remote-tracking refs | Always fetch before comparing with origin/ branches |
| Adding large files without LFS | Repository bloats irreversibly | Add .gitattributes LFS tracking rules early in the project |
Pro Tips
-
Default to
fetch.prune true. Rungit config --global fetch.prune trueonce and never manually prune again. Everygit fetchautomatically cleans stale references. -
Use
--filter=blob:noneover--depthfor development. Blobless partial clones give you full commit history (sogit log,git blame, andgit bisectwork correctly) while still being significantly smaller than full clones. -
Prefer subtrees for simple vendoring, submodules for shared libraries. If you're embedding a dependency that you rarely update, subtrees are simpler. If multiple projects share a library with its own release cycle, submodules give better control.
-
Set up LFS tracking before the first commit of binary files. Retroactively migrating to LFS requires history rewriting, which is painful in shared repositories. Get
.gitattributesright from day one. -
Use
git push --recurse-submodules=on-demandto automatically push submodule changes when you push the parent. This prevents the common mistake of pushing a parent commit that references submodule commits that don't exist on the remote. -
Create a shell alias for safe force push. Something like
alias gpf='git push --force-with-lease'saves keystrokes and makes the safe option the default.
Quiz / Self-Assessment
1. What is a bare repository, and why do remote servers use them?
Show Answer
A bare repository contains only Git's database (the contents of .git/) without a working directory. Remote servers use them because they never need to edit files directly — they only need to store and serve Git objects. The naming convention is project-name.git. You create one with git init --bare.
2. After forking a project on GitHub, how do you keep your fork in sync with the original?
Show Answer
- Add the original repository as a remote named
upstream:git remote add upstream <original-url> - Fetch from upstream:
git fetch upstream - Merge or rebase onto upstream's main:
git merge upstream/mainorgit rebase upstream/main - Push the updated branch to your fork:
git push origin main
3. What's the difference between git push --force and git push --force-with-lease?
Show Answer
--force unconditionally overwrites the remote branch, potentially destroying commits that others have pushed since your last fetch.
--force-with-lease checks that the remote branch is at the commit you expect (based on your last fetch). If someone has pushed new commits, the push is rejected, protecting their work. It's a safe alternative that should always be used instead of --force.
4. How do you remove stale remote-tracking branches that have been deleted on the remote?
Show Answer
Use git fetch --prune (or git remote prune origin) to remove local remote-tracking references for branches that no longer exist on the remote. To make this automatic, run git config --global fetch.prune true, which prunes on every fetch.
5. What are the trade-offs between shallow clones (--depth) and partial clones (--filter=blob:none)?
Show Answer
Shallow clones (--depth N) download only the last N commits and truncate history at a "graft" boundary. They're fast but break operations that need full history (git blame, git bisect, git merge-base). They're ideal for CI/CD where you only need the latest code.
Partial clones (--filter=blob:none) download all commits and tree objects but skip file content (blobs) until checked out. They preserve full history so git log, git blame, and git bisect work correctly. Blobs are lazily fetched on demand. They're better for development work on large repositories.
6. What is sparse checkout and when would you use it?
Show Answer
Sparse checkout tells Git to only materialize specific directories in your working tree, while keeping the full repository available. It's used in monorepos where you only work on a subset of the codebase. Combined with --filter=blob:none, it minimizes both download size and disk usage. You configure it with git sparse-checkout set --cone <directories>.
7. How do submodules differ from subtrees?
Show Answer
Submodules store a pointer (commit hash) to an external repository. The external repo remains independent with its own history. Cloning requires --recurse-submodules, and submodules default to detached HEAD. They're best for shared libraries with independent release cycles.
Subtrees merge external repository content directly into your repo's tree. No special clone steps are needed, and the code is version-controlled as part of your project. Contributing changes back (subtree push) is more complex. They're best for vendoring dependencies you rarely update.
8. A colleague reports that after cloning your project, the libs/shared-utils/ directory is empty. What's the likely cause and fix?
Show Answer
libs/shared-utils/ is likely a Git submodule. The colleague cloned without --recurse-submodules, so the submodule directory exists but its contents weren't downloaded.
Fix: Run git submodule update --init --recursive inside the cloned project. For future clones, use git clone --recurse-submodules <url>.
9. You've been committing 50MB video files to your repo for months without LFS. How do you fix this?
Show Answer
Use git lfs migrate import to retroactively convert existing files to LFS:
git lfs install
git lfs migrate import --include="*.mp4" --everythingWarning: This rewrites the entire repository history. All team members must re-clone after the migration. Coordinate with your team, and consider doing this during a maintenance window. To prevent future issues, configure LFS tracking (git lfs track) before the first commit of binary files.
10. You need to force push after a rebase, but --force-with-lease is rejected. What happened and what should you do?
Show Answer
The rejection means the remote branch has been updated since your last fetch — someone else pushed new commits. --force-with-lease is protecting their work.
Steps:
git fetch originto get the latest remote state- Review what was pushed:
git log --oneline HEAD..origin/feature - Rebase again on top of the new commits:
git rebase origin/feature - Resolve any conflicts
- Try again:
git push --force-with-lease origin feature
Never "work around" this by switching to --force, as that would destroy the other person's commits.