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
Time: 2-3 hours
Outcome: Working
branch and checkout commandsHow Branches Work
A branch in Git is just a file containing a 40-character commit hash. That’s it!Copy
┌─────────────────────────────────────────────────────────────────────────────┐
│ 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
Copy
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:- Updates HEAD to point to the target branch/commit
- Updates the working directory to match
src/commands/checkout.js
Copy
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
Copy
#!/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
Copy
# 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
Copy
# 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!
Copy
Normal: Detached:
HEAD → main → commit C HEAD → commit B
┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐
│ A │◄───│ B │◄───│ C │ │ A │◄───│ B │◄───│ C │
└───┘ └───┘ └───┘ └───┘ └───┘ └───┘
▲
HEAD
Exercises
Exercise 1: Implement checkout for files
Exercise 1: Implement checkout for files
Allow checking out individual files from a commit:
Copy
mygit checkout abc1234 -- file.txt
# Restore file.txt from commit abc1234
Exercise 2: Implement switch command
Exercise 2: Implement switch command
Modern Git has a separate
switch command (safer than checkout):Copy
mygit switch feature # Switch branches
mygit switch -c new-branch # Create and switch
Exercise 3: Add branch tracking
Exercise 3: Add branch tracking
Track which branch HEAD is on in status:
Copy
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:- Merge: Combine branches with three-way merge
- Rebase: Replay commits on a different base
- Diff: Show file differences
- Remote: Push and pull from other repositories
- 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