Skip to main content

Git Advanced Techniques

Advanced Git techniques beyond daily workflows. These require solid Git fundamentals and careful application.

Overview

This guide covers advanced Git operations that go beyond daily workflows and troubleshooting. Use these techniques when standard workflows are insufficient.

Prerequisites

Interactive Rebase

Interactive rebase rewrites commit history by replaying commits with modifications. Git creates new commits with different SHAs by changing the parent commit reference or commit content. Each commit's SHA is a hash of: parent SHA + tree SHA + author + committer + timestamp + message. Changing any of these creates a new SHA, which is why rebase rewrites history.

When to Use Interactive Rebase

Good use cases:

  • [GOOD] Cleaning up messy commit history before creating MR
  • [GOOD] Squashing "fix typo" commits into the main feature commit
  • [GOOD] Reordering commits to tell a logical story
  • [GOOD] Splitting large commits into focused, reviewable pieces
  • [GOOD] Editing commit messages for clarity

When NOT to use:

  • [BAD] Commits already pushed to main or develop
  • [BAD] Shared feature branches with multiple developers
  • [BAD] When uncomfortable with history manipulation

Starting Interactive Rebase

# Rebase last 5 commits
git rebase -i HEAD~5
# HEAD~5 means "5 commits before current HEAD"
# Opens editor showing last 5 commits in reverse chronological order (oldest first)

# Rebase all commits since branching from develop
git rebase -i develop
# Shows all commits on current branch that aren't in develop
# Useful before creating MR to clean up entire feature branch

# Rebase from specific commit (inclusive)
git rebase -i abc123^
# The ^ means "include commit abc123 in the rebase"
# Without ^, rebase starts AFTER abc123

What happens: Git opens your configured editor (vim, nano, code, etc.) with a list of commits:

pick abc123 feat(payments): add payment validation
pick def456 fix typo in validation message
pick ghi789 test(payments): add validation tests
pick jkl012 feat(payments): add retry logic
pick mno345 WIP debugging
pick pqr678 fix(payments): address review feedback

# Rebase develop..feature/JIRA-1234 onto develop (6 commits)
#
# Commands:
# p, pick <commit> = use commit
# r, reword <commit> = use commit, but edit the commit message
# e, edit <commit> = use commit, but stop for amending
# s, squash <commit> = use commit, but meld into previous commit
# f, fixup <commit> = like "squash", but discard this commit's log message
# x, exec <command> = run command (the rest of the line) using shell
# b, break = stop here (continue rebase later with 'git rebase --continue')
# d, drop <commit> = remove commit
# l, label <label> = label current HEAD with a name
# t, reset <label> = reset HEAD to a label
# m, merge [-C <commit> | -c <commit>] <label> [# <oneline>]
#
# These lines can be re-ordered; they are executed from top to bottom.
# If you remove a line here THAT COMMIT WILL BE LOST.

Squashing Commits

Squashing combines multiple commits into one. Git applies all changes from both commits, then creates a single new commit with combined changes. The commit message defaults to combining both messages, which you can edit.

# In the rebase editor, change 'pick' to 'squash' or 's'
pick abc123 feat(payments): add payment validation
fixup def456 fix typo in validation message # Use fixup to discard message
pick ghi789 test(payments): add validation tests
pick jkl012 feat(payments): add retry logic
squash pqr678 fix(payments): address review feedback # Use squash to edit combined message

What each command does:

  • pick: Keep commit exactly as-is (default)
  • fixup: Merge this commit into the previous one, discard this commit's message entirely
  • squash: Merge this commit into the previous one, but combine commit messages (Git will open editor to edit combined message)

After saving, Git will:

  1. Apply abc123 (payment validation)
  2. Apply def456's changes into abc123 (creates new commit, discards def456 message)
  3. Apply ghi789 (tests)
  4. Apply jkl012 (retry logic)
  5. Apply pqr678's changes into jkl012 (opens editor to edit combined message)

Result: 3 commits instead of 6

  • Commit 1: feat(payments): add payment validation (abc123 + def456 combined)
  • Commit 2: test(payments): add validation tests (ghi789 unchanged)
  • Commit 3: feat(payments): add retry logic (jkl012 + pqr678 combined, with edited message)

Reordering Commits

Simply change the line order in the editor. Git applies commits from top to bottom.

# Before reordering (tests before implementation - illogical!)
pick abc123 test(payments): add integration tests
pick def456 feat(payments): add payment validation
pick ghi789 test(payments): add unit tests

# After reordering (implementation first, then tests - logical!)
pick def456 feat(payments): add payment validation
pick ghi789 test(payments): add unit tests
pick abc123 test(payments): add integration tests

Caution: Reordering can cause conflicts if later commits depend on earlier ones. For example, if ghi789 tests code introduced in def456, swapping their order will fail because the tests reference code that doesn't exist yet.

Editing Commit Messages

Use reword to change commit messages without changing code.

pick abc123 feat(payments): add validation
reword def456 fix typo # Will open editor to change message
pick ghi789 test(payments): add tests

When Git reaches the reword line, it pauses and opens your editor with the current commit message. You can change it to something better like fix(payments): correct validation error message, then save and close. Git continues the rebase automatically.

Splitting Commits

Split one large commit into multiple focused commits. This requires the edit command.

# Interactive rebase
git rebase -i HEAD~3

# Mark commit to split with 'edit'
edit abc123 feat(payments): add validation and retry logic # This commit does too much
pick def456 test(payments): add tests

When Git reaches the edit line, it pauses and leaves you in a detached HEAD state at that commit:

# Git has stopped at abc123. Now we'll split it.

# Step 1: Undo the commit but keep all changes staged
git reset HEAD^
# HEAD^ means "parent of HEAD" (the commit before abc123)
# This moves HEAD back one commit but leaves all abc123's changes in working directory
# Changes are UNSTAGED (not in staging area)

# Step 2: Check what changed
git status
# Shows all files from abc123 as modified but unstaged

# Step 3: Stage and commit first part (validation)
git add src/validation/PaymentValidator.java
git add src/validation/ValidationRule.java
git commit -m "feat(payments): add payment validation

Implements validation rules for amount, currency, and recipient fields.
Validates against business rules before processing payment."

# Step 4: Stage and commit second part (retry logic)
git add src/retry/RetryManager.java
git add src/retry/RetryPolicy.java
git commit -m "feat(payments): add retry logic with exponential backoff

Implements configurable retry mechanism for failed payment processing.
Uses exponential backoff to avoid overwhelming external systems."

# Step 5: Continue rebase
git rebase --continue
# Git will now apply the remaining commits (def456, etc.)

Result: One commit (abc123) is now two focused commits that are easier to review and understand.

Dropping Commits

Remove commits entirely from history.

pick abc123 feat(payments): add validation
drop def456 WIP experimental approach that didn't work # This commit disappears
pick ghi789 test(payments): add tests

Alternative: Delete the line entirely (same effect as drop):

pick abc123 feat(payments): add validation
# def456 line deleted completely
pick ghi789 test(payments): add tests

What happens: Git never applies def456. Its changes are completely removed from history.

Force Pushing After Rebase

Rebasing changes commit SHAs (history rewriting), so you must force push to update the remote branch.

# Always use --force-with-lease (NEVER plain --force)
git push --force-with-lease origin feature/JIRA-1234

# --force-with-lease checks that remote branch matches what your local Git knows
# If someone else pushed between your last fetch and now, it rejects the push
# This prevents accidentally overwriting teammate's work

# Plain --force (DANGEROUS - don't use):
git push --force origin feature/JIRA-1234
# Blindly overwrites remote branch, potentially losing teammate's commits

What to do if --force-with-lease fails:

# Error: stale info - remote branch has commits you don't have locally

# Step 1: Fetch latest remote state
git fetch origin

# Step 2: Check what commits remote has that you don't
git log feature/JIRA-1234..origin/feature/JIRA-1234

# Step 3: If those commits are from a teammate, coordinate with them
# If they're yours from another machine, either:
# Option A: Rebase again on top of remote branch
git rebase origin/feature/JIRA-1234

# Option B: Force push anyway (only if you're sure)
git push --force-with-lease origin feature/JIRA-1234
Never Rebase Shared History

If multiple developers work on the same branch:

  1. Communicate before rebasing: Tell teammates you're rewriting history
  2. Teammates must reset after your force push:
    git fetch origin
    git reset --hard origin/feature/JIRA-1234 # Discards local commits
  3. Consider using merge instead of rebase for shared branches

Cherry-Picking Commits

Cherry-picking applies a specific commit's changes to your current branch. Git calculates the diff between the commit and its parent (what changed), then applies that diff to your current HEAD as a new commit. The new commit has the same changes but different parent, timestamp, and SHA.

How it works internally:

  1. Git finds commit you're cherry-picking (e.g., abc123)
  2. Calculates diff: git diff abc123^ abc123 (parent vs commit)
  3. Applies diff to current HEAD: git apply <diff>
  4. Creates new commit with same message and author but different parent and SHA

Basic Cherry-Pick

# Apply one commit to current branch
git cherry-pick abc123
# Git creates a new commit on current branch with abc123's changes
# New commit has different SHA because parent is different

# Cherry-pick multiple specific commits
git cherry-pick abc123 def456 ghi789
# Applies commits one-by-one in the order specified

# Cherry-pick range of commits
git cherry-pick abc123..def456
# Applies all commits BETWEEN abc123 and def456 (EXCLUSIVE of abc123, INCLUSIVE of def456)
# Use this carefully - make sure you want all commits in the range

# Cherry-pick range INCLUDING start commit
git cherry-pick abc123^..def456
# The ^ includes abc123 in the range

Common Use Cases

Use Case 1: Apply hotfix to multiple release branches

# Scenario: Security vulnerability discovered in v1.5.0
# Need to apply same fix to v1.4.0 and develop

# Create fix on release/v1.5.0
git checkout release/v1.5.0
git checkout -b hotfix/security-patch
# ... fix vulnerability ...
git commit -m "fix(auth): patch authentication bypass vulnerability

Validates token signature before accepting authentication claims.
Prevents malicious tokens from bypassing authentication.

CVE-2024-12345" # Note the commit SHA: abc123

git push origin hotfix/security-patch
# Merge to release/v1.5.0 via MR

# Now apply same fix to v1.4.0
git checkout release/v1.4.0
git checkout -b hotfix/security-patch-v1.4
git cherry-pick abc123
# If conflicts occur (code different between versions), resolve them
git push origin hotfix/security-patch-v1.4

# Apply to develop
git checkout develop
git cherry-pick abc123
git push origin develop

Use Case 2: Pull specific feature from another branch

# Teammate implemented useful utility function in their feature branch
# You need it for your work, but their feature isn't ready to merge

# Their branch
git log --oneline feature/JIRA-2000
# abc123 feat(utils): add date formatting utility
# def456 test(utils): add tests for date formatting
# ghi789 wip: experimental feature

# Cherry-pick only the utility (not experimental stuff)
git checkout feature/JIRA-1234 # Your branch
git cherry-pick abc123 def456 # Get utility and its tests
# Now you have the utility in your branch without the experimental code

Use Case 3: Extract bug fix from feature branch

# You found and fixed a bug while working on a feature
# The fix should go to develop immediately, but feature isn't ready

# Your feature branch
git log --oneline feature/JIRA-1234
# abc123 feat(payments): new payment flow (WIP)
# def456 fix(payments): prevent race condition in balance updates # THIS FIX IS CRITICAL
# ghi789 wip: more payment flow work

# Cherry-pick just the bug fix to develop
git checkout develop
git cherry-pick def456
git push origin develop
# Now the fix is in develop (will go to production in next release)
# Your feature branch still has all commits (including the fix)

Cherry-Pick with Conflicts

If cherry-picked commit conflicts with current branch state, Git pauses for manual resolution.

git cherry-pick abc123
# CONFLICT (content): Merge conflict in src/PaymentService.java
# error: could not apply abc123... feat(payments): add validation

# Step 1: Check what conflicts
git status
# Unmerged paths:
# both modified: src/PaymentService.java

# Step 2: Open file and resolve conflicts (same as merge conflicts)
# Look for <<<<<<< HEAD, =======, >>>>>>> markers
vim src/PaymentService.java

# Step 3: Stage resolved files
git add src/PaymentService.java

# Step 4: Continue cherry-pick
git cherry-pick --continue
# Git creates commit with resolved changes

# Alternative: Abort if you change your mind
git cherry-pick --abort
# Returns to state before cherry-pick started

Cherry-Pick Without Committing

Review changes before committing, or combine multiple cherry-picks into one commit.

# Apply changes to working directory but don't commit
git cherry-pick -n abc123
# or
git cherry-pick --no-commit abc123

# Changes are staged but not committed
git status
# Changes to be committed:
# modified: src/PaymentService.java

# Review staged changes
git diff --staged

# Option 1: Commit as-is
git commit -m "fix(auth): cherry-pick security patch from v1.5.0"

# Option 2: Make additional changes before committing
vim src/PaymentService.java # Adjust for this branch's context
git add src/PaymentService.java
git commit -m "fix(auth): adapt security patch for v1.4.0 API"

# Option 3: Cherry-pick multiple commits into one
git cherry-pick -n abc123
git cherry-pick -n def456
git cherry-pick -n ghi789
git commit -m "feat(payments): combine validation improvements from multiple sources"

Preserving Original Author

Cherry-pick automatically preserves the original commit author. If you want to credit the original author AND yourself:

git cherry-pick abc123
git commit --amend
# Edit commit message to add:
# Co-authored-by: Jane Doe <[email protected]>
Cherry-Pick Limitations
  • Creates duplicate commits: Same logical change exists in two branches with different SHAs
  • Can cause conflicts: Especially when cherry-picking old commits onto diverged branches
  • Loses context: Cherry-pick applies diff without understanding broader context
  • Better alternative: Merge branches when possible to preserve full history

Use cherry-pick for selective fixes, not as primary integration method.


Bisect for Finding Bugs

Git bisect uses binary search to find which commit introduced a bug. Instead of testing every commit (O(n) complexity), bisect halves the search space with each test, achieving O(log n) complexity.

Example: Given 1000 commits between known-good and known-bad:

  • Linear search: potentially 500 tests (average case)
  • Binary search: ~10 tests (log₂(1000) ≈ 10)

How Bisect Works

Binary search maintains a range of commits between "known good" and "known bad". At each step:

  1. Git checks out the commit halfway between good and bad
  2. You test if bug exists
  3. If bug exists: bad endpoint moves to midpoint (search earlier half)
  4. If bug doesn't exist: good endpoint moves to midpoint (search later half)
  5. Repeat until range contains only one commit (the culprit)
Initial state: 1000 commits to search
Step 1: Test commit 500 → Bug exists → Search commits 1-500 (500 commits)
Step 2: Test commit 250 → No bug → Search commits 250-500 (250 commits)
Step 3: Test commit 375 → Bug exists → Search commits 250-375 (125 commits)
Step 4: Test commit 312 → No bug → Search commits 312-375 (63 commits)
...
Step 10: Test commit 387 → First bad commit found!

Manual Bisect Workflow

# Step 1: Start bisect session
git bisect start
# Git enters bisect mode (shown in prompt: (main|BISECTING))

# Step 2: Mark current commit as bad (bug exists now)
git bisect bad
# Or specify a commit
git bisect bad HEAD # Same as above
git bisect bad abc123 # Specific commit is bad

# Step 3: Mark last known good commit (bug didn't exist)
git bisect good v1.4.0
# Can use commit SHA, tag, branch name, or relative ref
# Git calculates: ~156 commits between v1.4.0 and bad commit
# Bisecting: 78 revisions left to test after this (roughly 7 steps)

# Git automatically checks out the middle commit
# HEAD is now at commit def456 (halfway between good and bad)

# Step 4: Test if bug exists
# Run your app, execute tests, try to reproduce bug
npm test
# or
./gradlew test
# or manually test the bug

# Step 5a: If bug exists at this commit
git bisect bad
# Git moves "bad" endpoint to current commit
# Checks out new middle commit in earlier half
# Bisecting: 39 revisions left to test after this (roughly 6 steps)

# Step 5b: If bug doesn't exist at this commit
git bisect good
# Git moves "good" endpoint to current commit
# Checks out new middle commit in later half
# Bisecting: 39 revisions left to test after this (roughly 6 steps)

# Repeat steps 4-5 until Git finds the culprit
# ...several iterations...

# Git eventually identifies the first bad commit:
# abc1234 is the first bad commit
# commit abc1234
# Author: Jane Doe <[email protected]>
# Date: Mon Jan 15 14:23:45 2025 -0500
#
# feat(payments): refactor payment validation

# Step 6: End bisect session
git bisect reset
# Returns to the branch you were on before starting bisect
# HEAD moves back to original position

Automated Bisect

Automate testing with a script that exits 0 for good commits, non-zero for bad commits. Git runs the script for each bisect step automatically.

Create test script:

#!/bin/bash
# test-payment-bug.sh

# Exit immediately if any command fails
set -e

# Run the specific test that fails when bug exists
npm test -- PaymentService.test.ts

# If we reach here, tests passed → good commit (script exits 0)
# If npm test fails, script exits with non-zero code → bad commit

Run automated bisect:

# Make script executable
chmod +x test-payment-bug.sh

# Start bisect and provide good/bad range
git bisect start HEAD v1.4.0
# HEAD is bad (bug exists now)
# v1.4.0 is good (bug didn't exist there)

# Run automated bisect
git bisect run ./test-payment-bug.sh

# Git automatically tests each commit:
# Bisecting: 78 revisions left to test after this
# running './test-payment-bug.sh'
# [test output]
# Bisecting: 39 revisions left to test after this
# running './test-payment-bug.sh'
# [test output]
# ...
# abc1234 is the first bad commit

# Git automatically resets when done

More complex test script:

#!/bin/bash
# bisect-test.sh

set -e

# Clean previous builds
./gradlew clean

# Build project
./gradlew build
# If build fails, this commit can't be tested (skip it with exit 125)
if [ $? -ne 0 ]; then
echo "Build failed - skipping this commit"
exit 125 # Special exit code: tells Git to skip this commit
fi

# Run specific failing test
./gradlew test --tests PaymentServiceTest.shouldProcessPaymentCorrectly

# If we get here, test passed → good commit (exit 0)
# If test failed, gradle exited non-zero → bad commit

Exit code meanings for bisect:

  • 0: Good commit (no bug)
  • 1-124, 126-255: Bad commit (bug exists)
  • 125: Skip this commit (can't test it - e.g., build broken)

Bisect Best Practices

Narrow the search range:

# Instead of searching all history
git bisect good v1.0.0 # Released 2 years ago - too far back

# Narrow to recent changes where bug likely introduced
git bisect good HEAD~50 # Last 50 commits
git bisect good v1.4.0 # Last release

Skip untestable commits:

# If current commit won't compile or can't be tested
git bisect skip
# Git chooses a different commit nearby and continues
# Use this for commits with broken builds, missing dependencies, etc.

# Skip multiple commits matching a pattern
git bisect skip abc123..def456
# Skips all commits in that range

Bisect with specific paths (only commits touching certain files):

# Only test commits that modified payment-related files
git bisect start -- src/payments/ src/models/Payment.java
# Ignores commits that didn't touch those paths
# Narrows search space significantly

Visualize bisect progress:

# See current bisect state
git bisect log
# Shows history of good/bad/skip markings

# Visualize which commits remain to test
git bisect visualize
# Opens gitk showing commits in search range

# Command-line visualization
git bisect visualize --oneline --graph

Bisect on merged branches (follow only first-parent commits):

# Only check commits on main branch (ignore merged branch commits)
git bisect start --first-parent HEAD v1.4.0
# Faster when bug is in a merge, not individual branch commits
Bisect for Performance Regressions

Bisect isn't just for bugs - find when performance degraded:

#!/bin/bash
# test-performance.sh

# Run performance test
./gradlew performanceTest

# Extract execution time (example using grep/awk)
TIME=$(cat build/reports/performance.log | grep "Execution time" | awk '{print $3}')

# Define acceptable threshold (500ms)
THRESHOLD=500

# Compare (bc for floating point comparison)
if (( $(echo "$TIME < $THRESHOLD" | bc -l) )); then
exit 0 # Good performance
else
exit 1 # Performance regression
fi
git bisect start HEAD v1.4.0
git bisect run ./test-performance.sh
# Finds exact commit that introduced slowdown

Reflog for Recovering Lost Commits

Reflog (reference log) records every movement of HEAD and branch refs in your local repository. Unlike commit history (which follows parent-child relationships), reflog is a chronological time-based log of where HEAD pointed. Git garbage collects reflog entries after 90 days (configurable), making reflog the "safety net" for recovering seemingly lost work.

What triggers reflog entries:

  • Commits: git commit
  • Checkouts: git checkout, git switch
  • Resets: git reset
  • Rebases: git rebase
  • Merges: git merge
  • Cherry-picks: git cherry-pick
  • Stash operations: git stash
  • Pulls: git pull
  • Amends: git commit --amend

Important: Reflog is LOCAL ONLY (not pushed to remote). Each developer has their own reflog.

Viewing Reflog

# View reflog for current branch
git reflog
# Output shows recent HEAD movements:
# abc123 HEAD@{0}: commit: feat(payments): add validation
# def456 HEAD@{1}: rebase finished: returning to refs/heads/feature/JIRA-1234
# ghi789 HEAD@{2}: rebase: feat(payments): initial implementation
# jkl012 HEAD@{3}: checkout: moving from develop to feature/JIRA-1234
# mno345 HEAD@{4}: commit: fix(auth): resolve token issue

# Each entry shows:
# - Commit SHA (abc123)
# - Reflog reference (HEAD@{0} = most recent)
# - Operation that moved HEAD (commit, rebase, checkout, etc.)
# - Description of the operation

# View reflog for specific branch (not just HEAD)
git reflog show feature/JIRA-1234
# Shows all movements of feature/JIRA-1234 ref

# View reflog with dates instead of relative refs
git reflog --date=iso
# abc123 HEAD@{2025-01-15 14:23:45 -0500}: commit: feat(payments): add validation

# View reflog with relative dates (easier to read)
git reflog --date=relative
# abc123 HEAD@{2 hours ago}: commit: feat(payments): add validation
# def456 HEAD@{1 day ago}: checkout: moving to feature/JIRA-1234

# Limit reflog entries
git reflog -10 # Last 10 entries
git reflog -n 5 # Last 5 entries

Recovery Scenario 1: Undo Accidental Hard Reset

# You're on feature branch with 5 commits
git log --oneline
# abc123 feat(payments): commit 5
# def456 feat(payments): commit 4
# ghi789 feat(payments): commit 3
# jkl012 feat(payments): commit 2
# mno345 feat(payments): commit 1

# Accidentally reset, losing 5 commits
git reset --hard HEAD~5
# HEAD is now at pqr678 (5 commits before)

# Oh no! Lost all feature work. Check reflog:
git reflog
# abc123 HEAD@{0}: reset: moving to HEAD~5 ← The mistake
# abc123 HEAD@{1}: commit: feat(payments): commit 5 ← Lost commit
# def456 HEAD@{2}: commit: feat(payments): commit 4
# ...

# Recover by resetting to before the bad reset
git reset --hard HEAD@{1}
# HEAD is now at abc123 (back to commit 5)

# Or use the commit SHA directly
git reset --hard abc123

# Verify recovery
git log --oneline
# abc123 feat(payments): commit 5 ← Recovered!
# def456 feat(payments): commit 4
# ...

Recovery Scenario 2: Recover Deleted Branch

# Accidentally delete branch
git branch -D feature/JIRA-1234
# Deleted branch feature/JIRA-1234 (was abc123).

# Panic! But reflog remembers:
git reflog
# abc123 HEAD@{0}: checkout: moving from feature/JIRA-1234 to develop ← Last position
# def456 HEAD@{1}: commit: feat(payments): final commit on deleted branch
# ghi789 HEAD@{2}: commit: feat(payments): another commit
# ...

# Recreate branch at last commit
git checkout -b feature/JIRA-1234 abc123
# Branch 'feature/JIRA-1234' set up to track local branch 'feature/JIRA-1234'.
# Switched to a new branch 'feature/JIRA-1234'

# Or from reflog reference
git checkout -b feature/JIRA-1234 HEAD@{1}

# Verify recovery
git log --oneline
# def456 feat(payments): final commit on deleted branch
# ghi789 feat(payments): another commit
# ... all commits recovered!

# Push recovered branch
git push -u origin feature/JIRA-1234

Recovery Scenario 3: Recover from Bad Rebase

# Start rebase
git rebase develop
# Many conflicts...
# ... resolve conflicts poorly ...
git rebase --continue
# ... more conflicts ...
# Eventually finish, but result is broken

# Code doesn't work anymore. Undo entire rebase:
git reflog
# abc123 HEAD@{0}: rebase finished: returning to refs/heads/feature/JIRA-1234
# def456 HEAD@{1}: rebase: commit 3
# ghi789 HEAD@{2}: rebase: commit 2
# jkl012 HEAD@{3}: rebase: commit 1
# mno345 HEAD@{4}: rebase: checkout develop ← Start of rebase
# pqr678 HEAD@{5}: commit: my last commit before rebase ← Before rebase started

# Reset to before rebase started
git reset --hard HEAD@{5}
# Or
git reset --hard pqr678

# Branch is back to pre-rebase state
# Try rebase again more carefully, or use merge instead

Recovery Scenario 4: Recover Dropped Stash

# Stash some work
git stash save "WIP: payment validation"
# Saved working directory and index state

# Later, accidentally drop stash
git stash list
# stash@{0}: On feature/JIRA-1234: WIP: payment validation

git stash drop stash@{0}
# Dropped stash@{0} (abc123)

# Oh no! Need that work back. Check reflog:
git reflog
# abc123 HEAD@{0}: WIP on feature/JIRA-1234: payment validation ← Dropped stash
# def456 HEAD@{1}: commit: previous commit
# ...

# Recover stash contents by creating a branch from it
git checkout -b recover-stash abc123
# Switched to a new branch 'recover-stash'
# Now you have the stashed changes in a branch

# Or apply the stash diff to current branch
git cherry-pick abc123
# Applied stashed changes as a commit

# Or create a new stash from the dropped one (advanced)
git stash store abc123 -m "Recovered stash"

Reflog Expiration and Configuration

# Check reflog expiration settings
git config --get gc.reflogExpire
# Default: 90 (days for reachable commits)

git config --get gc.reflogExpireUnreachable
# Default: 30 (days for unreachable/orphaned commits)

# Change expiration (make reflog last longer)
git config gc.reflogExpire 180 # 180 days instead of 90
git config gc.reflogExpireUnreachable 60 # 60 days instead of 30

# Disable reflog expiration (not recommended - fills disk)
git config gc.reflogExpire never

# Manually trigger garbage collection (cleans up old reflog entries)
git gc
# Removes reflog entries older than expiration settings
# Also compresses repository, removes orphaned objects

# Aggressive garbage collection
git gc --aggressive --prune=now
# More thorough cleanup (slower)
# --prune=now removes unreachable objects immediately (not just old ones)

Reachable vs Unreachable commits:

  • Reachable: Commit is in history of some branch, tag, or ref (expires after 90 days in reflog)
  • Unreachable: Orphaned commit not reachable from any ref (expires after 30 days in reflog)

Example:

# Create commit
git commit -m "test" # commit abc123

# Reset to parent (abc123 becomes unreachable)
git reset --hard HEAD^
# abc123 is now orphaned (not on any branch)

# abc123 exists in reflog for 30 days
git reflog
# abc123 HEAD@{0}: commit: test

# After 30 days, git gc will permanently delete abc123
# After 90 days, even reachable commits disappear from reflog (but stay in repo)
Reflog Saves the Day

Real-world scenarios where reflog saved developers:

  • Accidentally git reset --hard losing days of work → Recovered via reflog
  • Force-pushed wrong branch, overwriting teammate's commits → Recovered their work via reflog
  • Deleted branch before pushing, losing all commits → Recreated from reflog
  • Bad interactive rebase destroyed commit history → Reset to pre-rebase state

Golden rule: Before panicking about lost work, check reflog!


Submodules and Subtrees

Submodules and subtrees allow including external Git repositories within your repository. Both solve dependency management but with different trade-offs.

Git Submodules

Submodules link to external repositories at specific commit SHAs. Your main repository stores only a reference (pointer) to the submodule's commit, not the actual submodule content. This keeps your repository small but adds complexity.

How submodules work technically:

  1. .gitmodules file stores submodule configuration (URL, path)
  2. Git index stores submodule commit SHA as special "gitlink" entry (not a file or directory)
  3. Submodule content lives in separate .git directory (.git/modules/<name>)
  4. Submodule working directory is at specified path (e.g., libs/shared-lib)

When to use submodules:

  • [GOOD] Shared library used across multiple projects
  • [GOOD] External dependency you want pinned to specific version
  • [GOOD] Component developed by different team (they maintain it)
  • [GOOD] Need to track specific commit of external project
  • [GOOD] Want to keep main repository size small

Adding a Submodule

# Add submodule to specific path
git submodule add https://gitlab.com/bank/shared-lib.git libs/shared-lib
# Cloning into 'libs/shared-lib'...
# remote: Enumerating objects: 150, done.
# remote: Counting objects: 100% (150/150), done.
# remote: Compressing objects: 100% (95/95), done.
# remote: Total 150 (delta 45), reused 125 (delta 35), pack-reused 0
# Receiving objects: 100% (150/150), 89.43 KiB | 1.79 MiB/s, done.
# Resolving deltas: 100% (45/45), done.

# This creates:
# 1. libs/shared-lib/ directory with submodule content
# 2. .gitmodules file with submodule configuration
# 3. Submodule entry in Git index (special gitlink, not regular file)

# Add submodule tracking specific branch
git submodule add -b main https://gitlab.com/bank/shared-lib.git libs/shared-lib
# Submodule will track 'main' branch by default

# Check what was created
cat .gitmodules
# [submodule "libs/shared-lib"]
# path = libs/shared-lib
# url = https://gitlab.com/bank/shared-lib.git
# branch = main

git status
# On branch develop
# Changes to be committed:
# new file: .gitmodules
# new file: libs/shared-lib ← This is gitlink (160000 mode), not directory

# Commit submodule reference
git commit -m "chore: add shared-lib submodule at commit abc123"
git push origin develop

Cloning Repository with Submodules

# Option 1: Clone with automatic submodule initialization
git clone --recursive https://gitlab.com/bank/payment-service.git
# Clones main repo
# Initializes all submodules
# Recursively clones all submodule content
# Use this for most cases

# Option 2: Clone first, then initialize submodules manually
git clone https://gitlab.com/bank/payment-service.git
cd payment-service

# Check submodule status
git submodule status
# -abc123 libs/shared-lib ← Minus sign means "not initialized"

# Initialize and clone all submodules
git submodule init
# Submodule 'libs/shared-lib' (https://gitlab.com/bank/shared-lib.git) registered for path 'libs/shared-lib'

git submodule update
# Cloning into 'libs/shared-lib'...
# Submodule path 'libs/shared-lib': checked out 'abc123'

# Combined command (init + update)
git submodule update --init
# Initializes and updates in one command

# For nested submodules (submodules containing submodules)
git submodule update --init --recursive

Working with Submodules

Updating submodule to latest commit:

# Navigate into submodule directory
cd libs/shared-lib

# Submodules are in "detached HEAD" state by default
git status
# HEAD detached at abc123

# Check out branch to make changes
git checkout main
git pull origin main
# Updated to latest main commit (def456)

# Return to parent repository
cd ../..

# Check status in parent repo
git status
# On branch develop
# Changes not staged for commit:
# modified: libs/shared-lib (new commits)
# Submodule changes to be committed:
# Submodule libs/shared-lib abc123..def456 (1):
# > feat(utils): add new date formatter

# Commit updated submodule reference
git add libs/shared-lib
git commit -m "chore: update shared-lib to latest version with date formatter"
git push origin develop

Update all submodules to their latest commits:

# Update all submodules to latest commit on their tracked branch
git submodule update --remote
# Submodule path 'libs/shared-lib': checked out 'def456'

# Update specific submodule
git submodule update --remote libs/shared-lib

# Update and merge instead of checkout
git submodule update --remote --merge
# If you have local changes in submodule, merge remote changes

# Update and rebase instead of merge
git submodule update --remote --rebase

# After updating, commit in parent repo
git add libs/shared-lib
git commit -m "chore: update shared-lib to latest"

Making changes inside a submodule:

# Enter submodule
cd libs/shared-lib

# Create feature branch (don't commit to detached HEAD!)
git checkout -b feature/add-currency-formatter
# Switched to a new branch 'feature/add-currency-formatter'

# Make changes
echo "export function formatCurrency(amount) { ... }" >> src/formatters.ts

# Commit in submodule
git add src/formatters.ts
git commit -m "feat(formatters): add currency formatting utility"

# Push submodule changes
git push origin feature/add-currency-formatter
# Submodule changes are pushed to submodule's remote repository

# Return to parent repo
cd ../..

# Parent repo doesn't see uncommitted changes inside submodule
# It only tracks submodule's commit SHA

# Update parent repo to reference new submodule commit
git add libs/shared-lib
git commit -m "chore: update shared-lib with currency formatter

Submodule updated to feature/add-currency-formatter branch.
Adds formatCurrency() utility for payment displays."

# Push parent repo
git push origin develop

Removing a Submodule

# Modern Git (2.12+)
git submodule deinit libs/shared-lib
# Submodule 'libs/shared-lib' (https://gitlab.com/bank/shared-lib.git) unregistered for path 'libs/shared-lib'
# Clears working directory and .git/config entry

git rm libs/shared-lib
# rm 'libs/shared-lib'
# Removes gitlink from index and working directory
# Removes entry from .gitmodules

git commit -m "chore: remove shared-lib submodule"

# For older Git versions (manual removal)
# Step 1: Deinitialize
git submodule deinit -f libs/shared-lib

# Step 2: Remove from working tree and index
git rm -f libs/shared-lib

# Step 3: Remove submodule metadata
rm -rf .git/modules/libs/shared-lib

# Step 4: Commit
git commit -m "chore: remove shared-lib submodule"

Submodule Challenges and Solutions

Challenge 1: Easy to forget to push submodule changes

# You make changes in submodule and commit
cd libs/shared-lib
git commit -m "feat: add utility"
cd ../..

# Update parent to reference new commit
git add libs/shared-lib
git commit -m "chore: update shared-lib"
git push origin develop # Push parent

# But forgot to push submodule changes!
# cd libs/shared-lib
# git push origin main ← FORGOT THIS

# Teammates clone and get error:
# Fetched in submodule path 'libs/shared-lib', but it did not contain abc123.
# Direct fetching of that commit failed.

Solution: Push submodule before parent

# Configure Git to check this
git config push.recurseSubmodules check
# Git will abort push if submodule commits aren't pushed

# Or auto-push submodules when pushing parent
git config push.recurseSubmodules on-demand
# Git automatically pushes submodule changes when pushing parent

Challenge 2: Detached HEAD state is confusing

Submodules checkout to specific commit (detached HEAD), not a branch.

cd libs/shared-lib
git status
# HEAD detached at abc123
# This is confusing for developers

Solution: Always checkout branch before making changes

cd libs/shared-lib
git checkout main # Attach to branch
# Now commits will be on main, not detached HEAD

Challenge 3: Complexity for beginners

Multiple repos to manage, special update commands, easy to make mistakes.

Solution: Provide clear documentation and Git aliases

# .gitconfig aliases
[alias]
# Update all submodules
sup = submodule update --remote --merge

# Initialize and update submodules
sinit = submodule update --init --recursive

# Show submodule status
sst = submodule status --recursive

# Usage:
git sinit # After cloning
git sup # Update to latest
git sst # Check status

Git Subtrees

Subtrees merge external repository content directly into your repository. Unlike submodules (which store reference), subtrees copy all files, making them part of your repository's history. This simplifies workflows but increases repository size.

When to use subtrees:

  • [GOOD] Want simple workflow (no special commands needed)
  • Don't contribute back to external library frequently
  • [GOOD] Prefer unified repository history
  • [GOOD] Want external code visible in main repo
  • Don't mind larger repository size

Adding a Subtree

# Step 1: Add remote for external repository
git remote add shared-lib https://gitlab.com/bank/shared-lib.git
# Remote 'shared-lib' added

# Step 2: Pull subtree into subdirectory
git subtree add --prefix=libs/shared-lib shared-lib main --squash
# git fetch shared-lib main
# From https://gitlab.com/bank/shared-lib
# * branch main -> FETCH_HEAD
# Added dir 'libs/shared-lib'
# Squash commit '...' created

# This:
# - Fetches from shared-lib remote
# - Merges all files into libs/shared-lib/
# - Creates squash commit (all history as one commit)
# - Files are now part of YOUR repository (not external reference)

git log --oneline
# abc123 (HEAD -> develop) Merge commit '...' ← Subtree merge
# def456 Add 'libs/shared-lib/' from commit '...' ← Squash commit with all files

# Files are committed directly to your repo
ls libs/shared-lib/
# src/ tests/ package.json ← Real files, not gitlink

With or without --squash:

  • --squash: All external history becomes one commit (cleaner, recommended)
  • Without --squash: Entire external history merged into your repo (messy, huge history)

Updating a Subtree

# Pull latest changes from external repository
git fetch shared-lib main
git subtree pull --prefix=libs/shared-lib shared-lib main --squash
# Squash merge '...'
# From https://gitlab.com/bank/shared-lib
# * branch main -> FETCH_HEAD
# Merge made by the 'recursive' strategy.
# libs/shared-lib/src/utils.ts | 10 ++++++++--
# 1 file changed, 8 insertions(+), 2 deletions(-)

# New changes merged into libs/shared-lib/
# History shows as merge commit in your repo

Pushing Changes Back to External Repository

If you make changes in subtree and want to contribute back:

# Make changes in subtree directory
vim libs/shared-lib/src/utils.ts
git add libs/shared-lib/src/utils.ts
git commit -m "feat(utils): add new date formatter"

# Push changes from subtree back to external repo
git subtree push --prefix=libs/shared-lib shared-lib feature/date-formatter
# git push using: shared-lib feature/date-formatter
# Splitting commit 'abc123'...
# To https://gitlab.com/bank/shared-lib.git
# * [new branch] feature/date-formatter -> feature/date-formatter

# This:
# - Extracts commits affecting libs/shared-lib/
# - Creates new commits in external repo with only those changes
# - Pushes to specified branch in external repo

Subtree vs Submodule Comparison

FeatureSubmoduleSubtree
Repository sizeSmall (stores SHA ref only)Large (stores full content)
ComplexityHigh (special commands, init/update)Low (just git commands)
VisibilityHidden (external repo)Visible (merged into main repo)
CloneRequires --recursive or submodule initWorks like normal clone (no special commands)
Updatesgit submodule update --remotegit subtree pull
Detached HEADYes (common issue)No (just regular files)
Contributing backEasy (just push from submodule dir)Complex (git subtree push with splitting)
Best forActively developing shared componentConsuming external library rarely modified
HistorySeparate (external repo history)Merged (becomes part of your history)
Tooling supportBuilt-in (git submodule commands)Built-in (git subtree commands)

Recommendation:

  • Use submodules if actively developing shared component across projects
  • Use subtrees if consuming external library with rare updates
  • Use monorepo tools (Nx, Turborepo) for managing many interdependent projects

Git Hooks

Git hooks are scripts that execute automatically at specific Git workflow points. Covered in detail in Migration Guide - Git Hooks section.

For comprehensive coverage, see that section which includes:

  • Client-side hooks (pre-commit, commit-msg, pre-push, post-checkout)
  • Sharing hooks across team (core.hooksPath)
  • Husky for Node.js projects
  • Server-side hooks in GitLab CI/CD

Git LFS for Large Files

Git LFS (Large File Storage) manages large binary files by storing pointers in Git and actual content on LFS server. Git commits store ~125 byte text pointer containing SHA-256 hash and file size, while binary lives on LFS-compatible server.

When to Use

Use LFS for:

  • Design files (Sketch, Figma, Photoshop) >100KB
  • Videos, large images, audio files
  • ML models, datasets
  • Compiled binaries

Don't use for:

  • Source code (text compresses well)
  • Small files <100KB
  • Frequently changing binaries

Setup and Usage

# Install
brew install git-lfs # macOS
sudo apt install git-lfs # Ubuntu

# Initialize (one-time)
git lfs install

# Track file types
git lfs track "*.psd"
git lfs track "design/**"

# Commit .gitattributes
git add .gitattributes
git commit -m "chore: configure LFS"

# Add LFS files (automatic)
git add design/mockup.psd
git commit -m "docs: add mockup"

Cloning with LFS:

# Download LFS files during clone
git clone https://gitlab.com/company/repo.git

# Skip LFS download (faster)
GIT_LFS_SKIP_SMUDGE=1 git clone https://gitlab.com/company/repo.git
git lfs pull # Download later

Migrating existing files:

# Migrate MP4 files in history to LFS
git lfs migrate import --include="*.mp4"

# Force push (rewrites history!)
git push --force-with-lease --all origin
Team Coordination Required

LFS migration rewrites history. All team members must re-clone after migration.


Advanced Merge Strategies

See GitFlow - Merge Strategies for standard strategies (squash, no-ff, rebase).

Recursive Strategy Options

Git's default merge strategy supports options for conflict resolution:

# Auto-resolve conflicts favoring current branch
git merge -X ours feature/JIRA-1234

# Auto-resolve conflicts favoring incoming branch
git merge -X theirs feature/JIRA-1234

# Ignore whitespace during merge
git merge -X ignore-all-space feature/JIRA-1234

# Adjust rename detection sensitivity (0-100)
git merge -X rename-threshold=50 feature/JIRA-1234
# Lower = more aggressive (finds more renames, slower)
# Higher = less aggressive (misses renames, faster)

How -X ours/theirs works:

  • ONLY affects conflicting chunks, not entire file
  • Non-conflicting changes from both sides still merged
  • Example: If both branches modify same line differently, keeps ours/theirs version

Octopus Merge

Merges 3+ branches simultaneously. Avoid - use sequential merges instead.

# Octopus (don't do this)
git merge branch1 branch2 branch3 # Aborts if ANY conflict

# Better: Sequential merges
git merge branch1 # Resolve conflicts
git merge branch2 # Resolve conflicts
git merge branch3 # Resolve conflicts

Further Reading

Internal Documentation

External Resources


Summary

Key Takeaways:

  1. Interactive Rebase: Squash, reorder, split commits before MR (solo branches only)
  2. Cherry-Pick: Apply specific commits across branches (use sparingly)
  3. Bisect: Binary search to find bug-introducing commit (O(log n) vs O(n))
  4. Reflog: Recover lost commits/branches (90-day safety net)
  5. Submodules: Link external repos (complex but keeps repo small)
  6. Subtrees: Merge external repos (simple but increases size)
  7. Git Hooks: Automate validation (see Migration Guide)
  8. Git LFS: Manage large binaries >100KB
  9. Force Push Safely: Always use --force-with-lease, never --force
  10. Team Communication: Coordinate before history-rewriting operations