Skip to main content

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.
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.

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
 */
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
    }
    
    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, not a branch. Any commits you make won’t belong to any branch and could be lost!
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 with:

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


Next Project

Ready for a bigger challenge? Move on to:

Build Your Own Redis

Master networking and data structures by building Redis from scratch