Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

Chapter 5: Branches & Checkout

Branches are one of Git’s most powerful features. In this chapter, we’ll implement branch management and the checkout command, completing our core Git implementation. Most developers think of branches as copies of the codebase. They are not. A branch in Git is a 41-byte text file (40 hex characters plus a newline) that contains a commit hash. Creating a branch is as cheap as creating a Post-it note — you are just writing down which commit the branch name should point to. This is why Git can have thousands of branches with zero performance overhead, while older version control systems like Subversion had to physically copy entire directory trees.
Prerequisites: Completed Chapter 4: Commits & History
Time: 2-3 hours
Outcome: Working branch and checkout commands

How Branches Work

A branch in Git is just a file containing a 40-character commit hash. That’s it!
┌─────────────────────────────────────────────────────────────────────────────┐
│                         GIT BRANCHES EXPLAINED                               │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│   .git/                                                                      │
│   ├── HEAD                     ← "ref: refs/heads/main"                     │
│   └── refs/                                                                  │
│       └── heads/                                                             │
│           ├── main             ← Contains: "abc123..."                      │
│           └── feature          ← Contains: "def456..."                      │
│                                                                              │
│                                                                              │
│   COMMIT GRAPH:                                                              │
│                                                                              │
│           feature                                                            │
│              │                                                               │
│              ▼                                                               │
│   ┌───┐    ┌───┐    ┌───┐                                                   │
│   │ A │◄───│ B │◄───│ D │                                                   │
│   └───┘    └───┘    └───┘                                                   │
│              │                                                               │
│              │      ┌───┐                                                   │
│              └──────│ C │◄──── main (HEAD)                                  │
│                     └───┘                                                   │
│                                                                              │
│   HEAD tells us: "We're on branch main"                                      │
│   main tells us: "Current commit is C"                                       │
│   feature tells us: "That branch is at D"                                    │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘
The key insight: Creating a branch is just creating a file. Switching branches is just changing HEAD and updating the working directory. The “cost” of a branch is literally 41 bytes on disk. This is why Git workflows like Git Flow, GitHub Flow, and trunk-based development with short-lived feature branches are practical — branching is essentially free.

Implementation

Step 1: Implement the Branch Command

src/commands/branch.js
const fs = require('fs');
const path = require('path');
const { requireGitDir } = require('../utils/paths');
const { readObject } = require('../utils/objects');

/**
 * branch - List, create, or delete branches
 * 
 * Usage:
 *   mygit branch              # List all branches
 *   mygit branch <name>       # Create new branch
 *   mygit branch -d <name>    # Delete branch
 *   mygit branch -m <new>     # Rename current branch
 */
function execute(args) {
    const gitDir = requireGitDir();
    
    // No arguments: list branches
    if (args.length === 0) {
        listBranches(gitDir);
        return;
    }
    
    const option = args[0];
    
    if (option === '-d' || option === '--delete') {
        // Delete branch
        const branchName = args[1];
        if (!branchName) {
            throw new Error('branch name required');
        }
        deleteBranch(gitDir, branchName);
    } else if (option === '-m' || option === '--move') {
        // Rename current branch
        const newName = args[1];
        if (!newName) {
            throw new Error('new branch name required');
        }
        renameBranch(gitDir, newName);
    } else if (option === '-a' || option === '--all') {
        // List all branches (including remote)
        listBranches(gitDir, true);
    } else if (!option.startsWith('-')) {
        // Create new branch
        const branchName = option;
        const startPoint = args[1]; // Optional starting commit
        createBranch(gitDir, branchName, startPoint);
    } else {
        throw new Error(`unknown option: ${option}`);
    }
}

/**
 * List all local branches
 */
function listBranches(gitDir, includeRemote = false) {
    const headsDir = path.join(gitDir, 'refs', 'heads');
    const currentBranch = getCurrentBranch(gitDir);
    
    if (!fs.existsSync(headsDir)) {
        return;
    }
    
    const branches = fs.readdirSync(headsDir)
        .filter(name => {
            const branchPath = path.join(headsDir, name);
            return fs.statSync(branchPath).isFile();
        })
        .sort();
    
    for (const branch of branches) {
        const isCurrent = branch === currentBranch;
        const prefix = isCurrent ? '* ' : '  ';
        const color = isCurrent ? '\x1b[32m' : '';
        const reset = isCurrent ? '\x1b[0m' : '';
        
        console.log(`${prefix}${color}${branch}${reset}`);
    }
}

/**
 * Create a new branch
 */
function createBranch(gitDir, branchName, startPoint = null) {
    // Validate branch name
    if (!isValidBranchName(branchName)) {
        throw new Error(`'${branchName}' is not a valid branch name`);
    }
    
    const branchPath = path.join(gitDir, 'refs', 'heads', branchName);
    
    // Check if branch already exists
    if (fs.existsSync(branchPath)) {
        throw new Error(`A branch named '${branchName}' already exists`);
    }
    
    // Get the commit to point to
    let commitHash;
    if (startPoint) {
        commitHash = resolveRef(gitDir, startPoint);
    } else {
        commitHash = getHead(gitDir);
    }
    
    if (!commitHash) {
        throw new Error('fatal: Not a valid object name: HEAD');
    }
    
    // Create the branch file
    fs.writeFileSync(branchPath, commitHash + '\n');
}

/**
 * Delete a branch
 */
function deleteBranch(gitDir, branchName) {
    const currentBranch = getCurrentBranch(gitDir);
    
    if (branchName === currentBranch) {
        throw new Error(`Cannot delete branch '${branchName}' checked out at '${path.dirname(gitDir)}'`);
    }
    
    const branchPath = path.join(gitDir, 'refs', 'heads', branchName);
    
    if (!fs.existsSync(branchPath)) {
        throw new Error(`branch '${branchName}' not found`);
    }
    
    // Get the commit hash before deleting (for message)
    const commitHash = fs.readFileSync(branchPath, 'utf8').trim();
    
    fs.unlinkSync(branchPath);
    
    console.log(`Deleted branch ${branchName} (was ${commitHash.slice(0, 7)})`);
}

/**
 * Rename current branch
 */
function renameBranch(gitDir, newName) {
    const currentBranch = getCurrentBranch(gitDir);
    
    if (!currentBranch) {
        throw new Error('fatal: HEAD is detached');
    }
    
    if (!isValidBranchName(newName)) {
        throw new Error(`'${newName}' is not a valid branch name`);
    }
    
    const oldPath = path.join(gitDir, 'refs', 'heads', currentBranch);
    const newPath = path.join(gitDir, 'refs', 'heads', newName);
    
    if (fs.existsSync(newPath)) {
        throw new Error(`A branch named '${newName}' already exists`);
    }
    
    // Rename the file
    fs.renameSync(oldPath, newPath);
    
    // Update HEAD
    const headPath = path.join(gitDir, 'HEAD');
    fs.writeFileSync(headPath, `ref: refs/heads/${newName}\n`);
    
    console.log(`Branch '${currentBranch}' renamed to '${newName}'`);
}

/**
 * Get current branch name (or null if detached)
 */
function getCurrentBranch(gitDir) {
    const headPath = path.join(gitDir, 'HEAD');
    const headContent = fs.readFileSync(headPath, 'utf8').trim();
    
    if (headContent.startsWith('ref: refs/heads/')) {
        return headContent.slice('ref: refs/heads/'.length);
    }
    
    return null;
}

/**
 * Get HEAD commit hash
 */
function getHead(gitDir) {
    const headPath = path.join(gitDir, 'HEAD');
    const headContent = fs.readFileSync(headPath, 'utf8').trim();
    
    if (headContent.startsWith('ref: ')) {
        const refPath = headContent.slice(5);
        const refFile = path.join(gitDir, refPath);
        
        if (fs.existsSync(refFile)) {
            return fs.readFileSync(refFile, 'utf8').trim();
        }
        return null;
    }
    
    return headContent;
}

/**
 * Resolve a ref name to a commit hash
 */
function resolveRef(gitDir, name) {
    // Check if it's a branch
    const branchPath = path.join(gitDir, 'refs', 'heads', name);
    if (fs.existsSync(branchPath)) {
        return fs.readFileSync(branchPath, 'utf8').trim();
    }
    
    // Check if it's a full hash
    if (/^[0-9a-f]{40}$/.test(name)) {
        return name;
    }
    
    // Check if it's a short hash
    if (/^[0-9a-f]{4,39}$/.test(name)) {
        const objectDir = path.join(gitDir, 'objects', name.slice(0, 2));
        if (fs.existsSync(objectDir)) {
            const matches = fs.readdirSync(objectDir)
                .filter(f => f.startsWith(name.slice(2)));
            
            if (matches.length === 1) {
                return name.slice(0, 2) + matches[0];
            }
        }
    }
    
    throw new Error(`Not a valid object name: '${name}'`);
}

/**
 * Validate branch name
 */
function isValidBranchName(name) {
    // Basic validation
    if (!name || name.length === 0) return false;
    if (name.startsWith('-')) return false;
    if (name.includes('..')) return false;
    if (name.includes(' ')) return false;
    if (name.endsWith('.lock')) return false;
    if (name === 'HEAD') return false;
    
    return true;
}

module.exports = { execute };

Step 2: Implement the Checkout Command

The checkout command does two things:
  1. Updates HEAD to point to the target branch/commit
  2. Updates the working directory to match
src/commands/checkout.js
const fs = require('fs');
const path = require('path');
const { requireGitDir, getRepoRoot } = require('../utils/paths');
const { readObject } = require('../utils/objects');
const { Index } = require('../utils/index');

/**
 * checkout - Switch branches or restore working tree files
 * 
 * Usage:
 *   mygit checkout <branch>      # Switch to branch
 *   mygit checkout -b <new>      # Create and switch to new branch
 *   mygit checkout <commit>      # Detached HEAD at commit
 */
function execute(args) {
    if (args.length === 0) {
        throw new Error('no branch specified');
    }
    
    const gitDir = requireGitDir();
    const repoRoot = getRepoRoot(gitDir);
    
    // Check for -b flag (create and switch)
    if (args[0] === '-b') {
        if (!args[1]) {
            throw new Error('branch name required');
        }
        createAndSwitch(gitDir, repoRoot, args[1], args[2]);
        return;
    }
    
    const target = args[0];
    switchTo(gitDir, repoRoot, target);
}

/**
 * Create a new branch and switch to it
 */
function createAndSwitch(gitDir, repoRoot, branchName, startPoint = null) {
    // Create the branch (reuse logic from branch command)
    const branchPath = path.join(gitDir, 'refs', 'heads', branchName);
    
    if (fs.existsSync(branchPath)) {
        throw new Error(`A branch named '${branchName}' already exists`);
    }
    
    // Get starting commit
    let commitHash;
    if (startPoint) {
        commitHash = resolveRef(gitDir, startPoint);
    } else {
        commitHash = getHead(gitDir);
    }
    
    if (!commitHash) {
        throw new Error('Not a valid starting point');
    }
    
    // Create branch
    fs.writeFileSync(branchPath, commitHash + '\n');
    
    // Switch to it
    const headPath = path.join(gitDir, 'HEAD');
    fs.writeFileSync(headPath, `ref: refs/heads/${branchName}\n`);
    
    console.log(`Switched to a new branch '${branchName}'`);
}

/**
 * Switch to a branch or commit
 */
function switchTo(gitDir, repoRoot, target) {
    // Check for uncommitted changes
    if (hasUncommittedChanges(gitDir, repoRoot)) {
        throw new Error(
            'error: Your local changes would be overwritten by checkout.\n' +
            'Please commit your changes or stash them before you switch branches.'
        );
    }
    
    // Try to resolve as a branch first
    const branchPath = path.join(gitDir, 'refs', 'heads', target);
    const isBranch = fs.existsSync(branchPath);
    
    let targetCommit;
    if (isBranch) {
        targetCommit = fs.readFileSync(branchPath, 'utf8').trim();
    } else {
        // Try to resolve as a commit hash
        targetCommit = resolveRef(gitDir, target);
    }
    
    // Get the tree from the target commit
    const { content } = readObject(gitDir, targetCommit);
    const commitText = content.toString();
    const treeMatch = commitText.match(/^tree ([a-f0-9]{40})/m);
    
    if (!treeMatch) {
        throw new Error('Invalid commit object');
    }
    
    const treeHash = treeMatch[1];
    
    // Update working directory
    updateWorkingDirectory(gitDir, repoRoot, treeHash);
    
    // Update index to match tree
    updateIndex(gitDir, repoRoot, treeHash);
    
    // Update HEAD
    const headPath = path.join(gitDir, 'HEAD');
    if (isBranch) {
        fs.writeFileSync(headPath, `ref: refs/heads/${target}\n`);
        console.log(`Switched to branch '${target}'`);
    } else {
        fs.writeFileSync(headPath, targetCommit + '\n');
        console.log(`Note: switching to '${target}'.`);
        console.log();
        console.log("You are in 'detached HEAD' state.");
        console.log(`HEAD is now at ${targetCommit.slice(0, 7)}`);
    }
}

/**
 * Check for uncommitted changes.
 *
 * This is the safety net that prevents checkout from silently destroying
 * your work. Real Git checks both staged and unstaged changes before
 * allowing a branch switch. Our simplified version compares file sizes as
 * a quick heuristic.
 *
 * Pitfall: checking only file size is a rough heuristic. Two files can have
 * the same byte count but different content. A more robust check would also
 * compare mtime (fast path) or re-hash the file (slow but accurate).
 * Real Git uses the stat cache in the index to make this check very fast.
 */
function hasUncommittedChanges(gitDir, repoRoot) {
    const index = Index.read(gitDir);
    const entries = index.getEntries();
    
    for (const entry of entries) {
        const filePath = path.join(repoRoot, entry.name);
        
        if (!fs.existsSync(filePath)) {
            return true; // Deleted file
        }
        
        const stat = fs.statSync(filePath);
        
        // Quick check: size changed?
        if (stat.size !== entry.size) {
            return true;
        }
        
        // TODO: Could also check mtime and content hash for more accuracy.
        // Real Git checks mtime first (fast), and only re-hashes if mtime changed.
    }
    
    return false;
}

/**
 * Update working directory to match a tree
 */
function updateWorkingDirectory(gitDir, repoRoot, treeHash) {
    // Get current files in working directory (that we track)
    const index = Index.read(gitDir);
    const currentFiles = new Set(index.getEntries().map(e => e.name));
    
    // Get files in target tree
    const targetFiles = getTreeFiles(gitDir, treeHash, '');
    
    // Remove files that are in current but not in target
    for (const file of currentFiles) {
        if (!targetFiles.has(file)) {
            const filePath = path.join(repoRoot, file);
            if (fs.existsSync(filePath)) {
                fs.unlinkSync(filePath);
                removeEmptyDirs(path.dirname(filePath), repoRoot);
            }
        }
    }
    
    // Create/update files from target tree
    for (const [filePath, hash] of targetFiles) {
        const fullPath = path.join(repoRoot, filePath);
        const dir = path.dirname(fullPath);
        
        if (!fs.existsSync(dir)) {
            fs.mkdirSync(dir, { recursive: true });
        }
        
        const { content } = readObject(gitDir, hash);
        fs.writeFileSync(fullPath, content);
    }
}

/**
 * Get all files from a tree recursively
 * Returns Map of path -> blob hash
 */
function getTreeFiles(gitDir, treeHash, prefix) {
    const result = new Map();
    const { content } = readObject(gitDir, treeHash);
    
    let offset = 0;
    while (offset < content.length) {
        const spaceIndex = content.indexOf(0x20, offset);
        const mode = content.slice(offset, spaceIndex).toString();
        
        const nullIndex = content.indexOf(0, spaceIndex);
        const name = content.slice(spaceIndex + 1, nullIndex).toString();
        
        const hashBytes = content.slice(nullIndex + 1, nullIndex + 21);
        const hash = hashBytes.toString('hex');
        
        offset = nullIndex + 21;
        
        const fullPath = prefix ? `${prefix}/${name}` : name;
        
        if (mode === '40000') {
            // Recurse into subtree
            const subFiles = getTreeFiles(gitDir, hash, fullPath);
            for (const [subPath, subHash] of subFiles) {
                result.set(subPath, subHash);
            }
        } else {
            result.set(fullPath, hash);
        }
    }
    
    return result;
}

/**
 * Update index to match a tree
 */
function updateIndex(gitDir, repoRoot, treeHash) {
    const index = new Index();
    const files = getTreeFiles(gitDir, treeHash, '');
    
    for (const [filePath, hash] of files) {
        const fullPath = path.join(repoRoot, filePath);
        const stat = fs.statSync(fullPath);
        
        index.addEntry({
            ctimeSeconds: Math.floor(stat.ctimeMs / 1000),
            ctimeNanoseconds: (stat.ctimeMs % 1000) * 1000000,
            mtimeSeconds: Math.floor(stat.mtimeMs / 1000),
            mtimeNanoseconds: (stat.mtimeMs % 1000) * 1000000,
            dev: stat.dev,
            ino: stat.ino,
            mode: stat.mode,
            uid: stat.uid,
            gid: stat.gid,
            size: stat.size,
            hash: hash,
            flags: Math.min(filePath.length, 0xFFF),
            name: filePath
        });
    }
    
    index.write(gitDir);
}

/**
 * Remove empty directories up to repo root
 */
function removeEmptyDirs(dir, repoRoot) {
    while (dir !== repoRoot && dir.length > repoRoot.length) {
        try {
            const files = fs.readdirSync(dir);
            if (files.length === 0) {
                fs.rmdirSync(dir);
                dir = path.dirname(dir);
            } else {
                break;
            }
        } catch {
            break;
        }
    }
}

/**
 * Get HEAD commit hash
 */
function getHead(gitDir) {
    const headPath = path.join(gitDir, 'HEAD');
    const headContent = fs.readFileSync(headPath, 'utf8').trim();
    
    if (headContent.startsWith('ref: ')) {
        const refPath = headContent.slice(5);
        const refFile = path.join(gitDir, refPath);
        
        if (fs.existsSync(refFile)) {
            return fs.readFileSync(refFile, 'utf8').trim();
        }
        return null;
    }
    
    return headContent;
}

/**
 * Resolve a ref to a commit hash
 */
function resolveRef(gitDir, name) {
    // Check if it's a branch
    const branchPath = path.join(gitDir, 'refs', 'heads', name);
    if (fs.existsSync(branchPath)) {
        return fs.readFileSync(branchPath, 'utf8').trim();
    }
    
    // Check if it's a hash
    if (/^[0-9a-f]{4,40}$/.test(name)) {
        if (name.length === 40) {
            return name;
        }
        
        const objectDir = path.join(gitDir, 'objects', name.slice(0, 2));
        if (fs.existsSync(objectDir)) {
            const matches = fs.readdirSync(objectDir)
                .filter(f => f.startsWith(name.slice(2)));
            
            if (matches.length === 1) {
                return name.slice(0, 2) + matches[0];
            }
        }
    }
    
    throw new Error(`pathspec '${name}' did not match any file(s) known to git`);
}

module.exports = { execute };

Step 3: Update CLI

src/mygit.js
#!/usr/bin/env node

const commands = {
    init: require('./commands/init'),
    'hash-object': require('./commands/hashObject'),
    'cat-file': require('./commands/catFile'),
    add: require('./commands/add'),
    status: require('./commands/status'),
    commit: require('./commands/commit'),
    log: require('./commands/log'),
    branch: require('./commands/branch'),
    checkout: require('./commands/checkout'),
};

function main() {
    const args = process.argv.slice(2);
    
    if (args.length === 0) {
        printUsage();
        process.exit(1);
    }
    
    const command = args[0];
    const commandArgs = args.slice(1);
    
    if (!commands[command]) {
        console.error(`mygit: '${command}' is not a mygit command.`);
        process.exit(1);
    }
    
    try {
        commands[command].execute(commandArgs);
    } catch (error) {
        console.error(error.message);
        process.exit(1);
    }
}

function printUsage() {
    console.log('usage: mygit <command> [<args>]');
    console.log();
    console.log('These are common mygit commands:');
    console.log();
    console.log('start a working area:');
    console.log('   init          Create an empty Git repository');
    console.log();
    console.log('work on the current change:');
    console.log('   add           Add file contents to the index');
    console.log('   status        Show the working tree status');
    console.log();
    console.log('examine the history and state:');
    console.log('   log           Show commit logs');
    console.log();
    console.log('grow, mark and tweak your common history:');
    console.log('   branch        List, create, or delete branches');
    console.log('   commit        Record changes to the repository');
    console.log('   checkout      Switch branches or restore files');
}

main();

Testing Your Implementation

# Set up a test repo
mygit init
echo "Initial content" > file.txt
mygit add file.txt
mygit commit -m "Initial commit"

# Create a new branch
mygit branch feature
mygit branch
#   feature
# * main

# Switch to the new branch
mygit checkout feature
# Switched to branch 'feature'

# Make changes on feature branch
echo "Feature content" >> file.txt
mygit add file.txt
mygit commit -m "Add feature"

# Switch back to main
mygit checkout main
# Switched to branch 'main'

# Verify file content reverted
cat file.txt
# Initial content

# Create and switch in one command
mygit checkout -b hotfix
# Switched to a new branch 'hotfix'

Understanding Detached HEAD

# Checkout a specific commit (not a branch)
mygit checkout abc1234

# You get:
# Note: switching to 'abc1234'.
# You are in 'detached HEAD' state.
Detached HEAD means HEAD points directly to a commit hash, not a branch name. Any commits you make in this state create “orphan” commits that no branch references. They will work fine while you’re there, but the moment you switch to a real branch, those commits become unreachable and will eventually be garbage-collected by git gc. The fix: before leaving detached HEAD, run git branch <name> to give your work a branch name that keeps it alive.
Normal:                      Detached:
                            
HEAD → main → commit C       HEAD → commit B
                            
┌───┐    ┌───┐    ┌───┐     ┌───┐    ┌───┐    ┌───┐
│ A │◄───│ B │◄───│ C │     │ A │◄───│ B │◄───│ C │
└───┘    └───┘    └───┘     └───┘    └───┘    └───┘

                                      HEAD

Exercises

Allow checking out individual files from a commit:
mygit checkout abc1234 -- file.txt
# Restore file.txt from commit abc1234
Modern Git has a separate switch command (safer than checkout):
mygit switch feature       # Switch branches
mygit switch -c new-branch # Create and switch
Track which branch HEAD is on in status:
mygit status
# On branch feature
# Your branch is ahead of 'main' by 2 commits.

Complete Git Clone!

Congratulations! You’ve built a working Git implementation. Take a moment to appreciate what you’ve done — you’ve implemented the same fundamental architecture that manages billions of lines of code across millions of repositories worldwide. The patterns you’ve learned (content-addressable storage, DAG-based history, cheap branching via pointer files) are not Git-specific; they appear in databases, distributed systems, and blockchain technology. Here is what you’ve built:

init

Initialize repositories

hash-object

Hash and store files

cat-file

Read stored objects

add

Stage changes

status

Show working tree

commit

Create commits

log

View history

branch

Manage branches

checkout

Switch branches

What You’ve Learned

Content-Addressable Storage

Files stored by their SHA-1 hash, enabling deduplication and integrity

Object Model

Blobs (files), trees (directories), and commits (snapshots)

The Index

Staging area as a binary file tracking what will be committed

Branches are Pointers

Just files containing commit hashes - incredibly simple!

Further Challenges

Ready for more? Try implementing:
  1. Merge: Combine branches with three-way merge
  2. Rebase: Replay commits on a different base
  3. Diff: Show file differences
  4. Remote: Push and pull from other repositories
  5. Pack files: Delta compression for efficiency

Further Reading

DSA: Graph Algorithms

Essential for understanding commit graphs

Distributed Systems

How Git enables distributed version control

Next Project

Ready for a bigger challenge? Move on to:

Build Your Own Redis

Master networking and data structures by building Redis from scratch

Interview Deep-Dive

Strong Answer:
  • First, Git checks for uncommitted changes that would be overwritten by the checkout. It compares the current index against the working directory to find modified files, then checks if those files differ between the current branch’s tree and the target branch’s tree. If a modified file would be overwritten, Git aborts with a safety error.
  • If safe, Git reads the target commit’s root tree and compares it to the current commit’s root tree. Files that differ are updated: Git reads the new blob from the object store and writes it to the working directory. Files that are identical are untouched (no I/O needed thanks to tree-level deduplication).
  • Files that exist in the current tree but not in the target tree are deleted. Files that exist in the target tree but not the current tree are created. Empty directories left behind are cleaned up.
  • The index is updated to match the target commit’s tree, with fresh stat cache entries (mtime, size, inode) from the newly written files.
  • Finally, HEAD is updated: if switching to a branch, HEAD is written as ref: refs/heads/<branch>; if checking out a commit hash, HEAD is written as the raw hash (detached HEAD).
  • The operation is optimized to touch the minimum number of files. For two branches that differ by one file in a 10,000-file repository, checkout reads one blob and writes one file.
Follow-up: Why does git checkout sometimes refuse to switch branches even when the modified file is the same on both branches?This is a conservative safety measure. Git compares the file’s content in the index (what was staged) against both branches. If the file is modified in the working directory but identical between the two branches’ trees, older versions of Git would still refuse because the index entry might differ from both. Modern Git (2.23+) introduced git switch, which is smarter about this: it checks whether the working directory changes can be preserved across the switch. The safety logic is intentionally conservative because losing uncommitted work is worse than a false refusal — the user can always stash, switch, and pop.
Strong Answer:
  • Detached HEAD means HEAD contains a raw commit hash instead of a symbolic reference to a branch. You enter this state by checking out a specific commit, a tag, or a remote tracking branch directly.
  • It exists because sometimes you need to inspect or build from a specific historical point without affecting any branch. CI/CD systems often check out a specific commit hash for reproducible builds. git bisect uses detached HEAD internally as it navigates the commit graph looking for a bug.
  • The risk is that commits made in detached HEAD state are not referenced by any branch. They are reachable only through HEAD and the reflog. If you switch to a branch, HEAD updates to point to the branch, and the detached commits become unreachable. They will be garbage collected after the reflog expires (default 30 days for unreachable entries, 90 days for reachable ones).
  • The recovery is simple if you realize in time: git branch <name> while still in detached HEAD creates a branch pointing to your current commit. If you already left, git reflog shows recent HEAD positions, and you can recover with git branch <name> <hash>.
Follow-up: How does git reflog work, and why is it the last line of defense for recovering lost commits?The reflog is a per-reference log stored in .git/logs/. Every time a ref (HEAD, branch, etc.) changes, Git appends an entry with the old hash, new hash, timestamp, and the operation that caused the change. git reflog shows this log for HEAD, letting you see every commit HEAD has pointed to, even ones that are no longer reachable from any branch. This is the “undo history” for Git itself. The reflog is local-only (not pushed or fetched), expires after a configurable period, and is the mechanism behind git reset --hard @{1} (go back to where HEAD was one move ago). It is the last line of defense because it records state transitions that the commit DAG does not — specifically, the sequence of HEAD movements that led to the current state.
Strong Answer:
  • A two-way merge compares only the two branch tips. If a line differs between them, the merge cannot tell which side changed it — maybe one side added it, or maybe one side deleted it, or both modified it differently. Two-way merge produces many false conflicts.
  • Three-way merge uses a common ancestor (the merge base) as the reference point. For each line, it compares: (1) base vs. branch A, (2) base vs. branch B. If only one side changed a line, the change is accepted automatically. If both sides changed the same line differently, that is a true conflict requiring manual resolution. If both sides made the same change, it is accepted once.
  • To implement it: find the merge base with a lowest common ancestor algorithm on the commit DAG. Read the three trees (base, ours, theirs). For each file, compare the blob hashes. If the file changed on only one side, take that version. If it changed on both sides, diff the content and merge line by line using the three-way algorithm. Write the result as a new tree, create a merge commit with two parents, and update the branch pointer.
  • The merge base is found using git merge-base, which walks both branches’ ancestor chains until it finds a common commit. For multiple merge bases (criss-cross merges), Git uses recursive merge strategy: it merges the merge bases first to create a virtual ancestor, then uses that as the base for the actual merge.
Follow-up: What is a fast-forward merge, and when does Git use it instead of a three-way merge?A fast-forward merge occurs when the current branch’s tip is a direct ancestor of the target branch’s tip — meaning the target branch has commits that the current branch does not, but the current branch has no commits that the target branch lacks. In this case, no merge commit is needed: Git simply moves the current branch pointer forward to the target commit. This produces a linear history with no merge commit. git merge --no-ff forces a merge commit even when fast-forward is possible, which some teams prefer because merge commits explicitly mark where feature branches were integrated. The choice between fast-forward and merge commits is a workflow decision, not a technical one — both produce correct results.