Git Version Manager: Ace Your Java LLD Interview
You're in the hot seat. The interviewer just dropped the bomb: "Design a simplified Git version manager in Java." Your mind races. This isn't just about remembering data structures; it's about showing how you think, how you structure a complex problem, and how you design a system that works, not just compiles. I've been there, on both sides of that table, and trust me, they're not looking for a perfect Git clone. They want to see your design chops, especially with object-oriented principles, and how you handle state and operations for a version control system.
The Core Concept: What Even Is Git?
Before you write a single class, pause. Git, at its heart, isn't about files; it's about snapshots. Every commit captures the entire state of your repository at that moment. This is a crucial distinction. Many junior engineers try to design Git around tracking file diffs, which is a massive red herring for LLD. Forget diffs initially. Think about immutable snapshots, content-addressable storage, and a directed acyclic graph (DAG) of commits. This foundational understanding immediately tells your interviewer you're thinking at the right level of abstraction. Your design for a Git version manager needs to reflect this snapshot-based philosophy from the ground up, especially when considering how you'll manage file content and history in Java.
Breaking Down the Problem: Key Components
Okay, so we're building a simplified Git. What absolutely must it do? Let's list the minimum viable features they expect in a typical 60-90 minute interview:
- Initialize a repository: Create an empty Git directory.
- Add files: Stage changes for the next commit.
- Commit changes: Create a snapshot of the repository's state.
- View commit history: See the sequence of commits.
- Checkout: Move to a specific commit or branch.
We'll skip remote operations, branching/merging complexities, and advanced features like rebasing. Stick to the basics. You want to show a solid foundation, not a sprawling, half-baked mess. If you get through the core, you can always discuss extensions.
Data Model First: The Building Blocks of Versioning
This is where you earn your stripes. Your data model dictates everything. Start with the core entities.
Blob, Tree, Commit: The Git Trinity
- Blob: Represents the content of a file. It's essentially a byte array. The key insight here: store content, not file paths. Git doesn't care about the filename until it's part of a tree. Each unique file content gets a unique ID (a SHA-1 hash in real Git, but for us, a simple UUID or even a hash of the content itself will do).
public class Blob { private String id; // SHA-1 hash of content private byte[] content; // File content public Blob(byte[] content) { this.content = content; this.id = calculateHash(content); // Implement SHA-1 or similar } // Getters and helper methods } - Tree: Represents a directory. It maps filenames to Blobs or other Trees. A Tree, like a Blob, is content-addressable. Its ID is a hash of its contents (the list of entries it contains). This nesting is how Git represents directory structures.
public class Tree { private String id; // SHA-1 hash of its entries private Map<String, Blob> fileEntries; // filename -> Blob ID private Map<String, Tree> directoryEntries; // directory name -> Tree ID public Tree() { this.fileEntries = new HashMap<>(); this.directoryEntries = new HashMap<>(); } // Methods to add/remove entries, calculate hash, etc. } - Commit: The big one. A commit points to a single root Tree (the state of the repository at that commit), a parent commit (or multiple parents for merges, but let's stick to one for now), an author, a timestamp, and a commit message. Its ID is a hash of all this information. This is what forms your DAG.
public class Commit { private String id; // SHA-1 hash of its contents private String treeId; // ID of the root Tree for this commit private String parentId; // ID of the parent Commit, null for initial commit private String author; private long timestamp; private String message; public Commit(String treeId, String parentId, String author, String message) { this.treeId = treeId; this.parentId = parentId; this.author = author; this.timestamp = System.currentTimeMillis(); this.message = message; this.id = calculateHash(); // Hash of all fields } // Getters }
Repository Management: The Git Object Store
You need a way to store these Blob, Tree, and Commit objects. A simple Map<String, Object> or Map<String, byte[]> mapping object IDs to their serialized content works for an in-memory or file-system-backed store. Real Git uses a .git/objects directory where objects are stored by their SHA-1 hash. Mimic this with a Map that simulates persistent storage.
public class ObjectStore {
// In a real system, this would be backed by files, not memory.
// For interview, a HashMap is fine, but mention the file system aspect.
private Map<String, byte[]> objects = new HashMap<>();
public void putObject(String id, byte[] data) {
objects.put(id, data);
}
public byte[] getObject(String id) {
return objects.get(id);
}
// Helper to serialize/deserialize objects
}
This ObjectStore is critical. It's how you retrieve any Git object given its ID.
The GitManager Class: Orchestrating Operations
Now, bring it all together into a GitManager class. This is your main entry point for all Git operations.
public class GitManager {
private ObjectStore objectStore;
private Path repositoryRoot; // Where the user's files live
private String head; // Points to the current commit ID
private Map<String, String> branches; // branchName -> commitId
public GitManager(Path repoRoot) {
this.repositoryRoot = repoRoot;
this.objectStore = new ObjectStore();
this.branches = new HashMap<>();
// Initialize HEAD to null or a default branch (e.g., "master" or "main")
this.head = null; // No commits yet
this.branches.put("main", null); // "main" branch exists, points to no commit
}
// ... public methods for Git operations ...
}
Initializing the Repository
init() simply creates the .git directory (conceptually, for our in-memory version, it just initializes the ObjectStore and branches map).
public void init() {
// In a real Git, this creates the .git directory structure.
// For our LLD, it ensures the object store and branches are ready.
System.out.println("Initialized empty Git repository.");
}
Adding Files: The Staging Area (Index)
This is a subtle but important part of Git. When you git add, files don't go directly into a commit. They go into a "staging area" or "index." This is a temporary Tree-like structure that represents what will be in the next commit.
public class Index {
private Map<String, String> stagedFiles; // filepath -> Blob ID
public Index() {
this.stagedFiles = new HashMap<>();
}
public void addFile(String filePath, String blobId) {
stagedFiles.put(filePath, blobId);
}
public Map<String, String> getStagedFiles() {
return stagedFiles;
}
// Clear after commit
public void clear() {
stagedFiles.clear();
}
}
Your GitManager will hold an instance of Index.
public class GitManager {
// ...
private Index index; // The staging area
public GitManager(Path repoRoot) {
// ...
this.index = new Index();
}
public void add(String filePath) throws IOException {
Path fullPath = repositoryRoot.resolve(filePath);
if (!Files.exists(fullPath) || Files.isDirectory(fullPath)) {
throw new IllegalArgumentException("File not found or is a directory: " + filePath);
}
byte[] content = Files.readAllBytes(fullPath);
Blob blob = new Blob(content);
objectStore.putObject(blob.getId(), serialize(blob)); // Store the blob
index.addFile(filePath, blob.getId());
System.out.printf("Added file %s (blob ID: %s)%n", filePath, blob.getId());
}
// ...
}
Committing Changes: Building the Snapshot
This is where the Blobs and Trees from the Index get formalized into a Commit.
- Build the root Tree: Take all the staged files from the
Indexand recursively build aTreestructure. This involves creating intermediateTrees for directories. - Store the Tree: Serialize and store all created
Treeobjects in theObjectStore. - Create the Commit: Instantiate a
Commitobject, linking it to the rootTreeID and the currentHEADas its parent. - Store the Commit: Serialize and store the
Commitobject. - Update HEAD and Branch: Point the current branch (and
HEAD) to the new commit. - Clear Index: The staging area is now empty.
public String commit(String message, String author) {
if (index.getStagedFiles().isEmpty()) {
System.out.println("No changes to commit. Staging area is empty.");
return null;
}
// 1. Build the root Tree from the staged files
// This part is tricky and often simplified in interviews.
// You need to parse file paths and build nested Tree objects.
// For simplicity, let's assume a flat structure for now or a helper method.
Tree rootTree = buildTreeFromIndex(index.getStagedFiles());
objectStore.putObject(rootTree.getId(), serialize(rootTree)); // Store the root tree
// 2. Create the Commit
String parentCommitId = branches.get(getCurrentBranch()); // Get parent from current branch
Commit commit = new Commit(rootTree.getId(), parentCommitId, author, message);
objectStore.putObject(commit.getId(), serialize(commit)); // Store the commit
// 3. Update HEAD and Branch
head = commit.getId();
branches.put(getCurrentBranch(), commit.getId()); // Update current branch to point to new commit
index.clear(); // Clear staging area
System.out.printf("Committed changes (ID: %s)%n", commit.getId());
return commit.getId();
}
private Tree buildTreeFromIndex(Map<String, String> stagedFiles) {
// This is a simplified version. A real Git builds a proper nested tree.
// For interview, you might just put all files directly into one root tree.
Tree root = new Tree();
for (Map.Entry<String, String> entry : stagedFiles.entrySet()) {
// Assume file path is just filename for simplicity, or
// implement logic to create nested trees for directories.
// E.g., for "src/main/App.java", you'd need a "src" tree, then a "main" tree.
root.addFileEntry(entry.getKey(), deserializeBlob(objectStore.getObject(entry.getValue())));
}
return root;
}
Realistically, building the Tree from the Index is quite involved. You'd traverse the staged file paths, creating Tree objects for each directory level and linking Blobs to their correct spots. For an interview, if time is tight, you might simplify and just say "the buildTreeFromIndex method recursively creates Tree objects based on the file paths in the index." That's a reasonable trade-off.
Viewing Commit History: Log
This is a simple traversal of the commit graph, starting from HEAD and following parentId pointers.
public void log() {
if (head == null) {
System.out.println("No commits yet.");
return;
}
String currentCommitId = head;
while (currentCommitId != null) {
Commit commit = deserializeCommit(objectStore.getObject(currentCommitId));
System.out.printf("Commit ID: %s%n", commit.getId());
System.out.printf("Author: %s%n", commit.getAuthor());
System.out.printf("Date: %s%n", new Date(commit.getTimestamp())) ;
System.out.printf("Message: %s%n%n", commit.getMessage());
currentCommitId = commit.getParentId();
}
}
Checkout: Recreating the Working Directory
This takes a commit ID (or branch name), finds its root Tree, and reconstructs the working directory based on that Tree's contents.
public void checkout(String commitOrBranchName) throws IOException {
String targetCommitId = branches.get(commitOrBranchName);
if (targetCommitId == null) {
targetCommitId = commitOrBranchName; // Assume it's a commit ID if not a branch
// You'd want to validate if this ID actually exists and is a commit
}
if (targetCommitId == null) {
throw new IllegalArgumentException("Commit or branch not found: " + commitOrBranchName);
}
Commit targetCommit = deserializeCommit(objectStore.getObject(targetCommitId));
if (targetCommit == null) {
throw new IllegalArgumentException("Invalid commit ID: " + targetCommitId);
}
// 1. Clear the current working directory (careful with real files!)
// For interview, you can simulate this or assume it's okay to overwrite.
clearWorkingDirectory();
// 2. Reconstruct files from the target commit's tree
Tree rootTree = deserializeTree(objectStore.getObject(targetCommit.getTreeId()));
reconstructWorkingDirectory(rootTree, repositoryRoot);
// 3. Update HEAD
head = targetCommitId;
// If checking out a branch, update the current branch pointer
if (branches.containsKey(commitOrBranchName)) {
// This means we're still on a branch, just moved its HEAD
} else {
// "Detached HEAD" state - for simplicity, you might skip this in LLD
System.out.println("Checked out to commit " + targetCommitId + " (detached HEAD state).");
}
System.out.printf("Checked out to commit %s%n", targetCommitId);
}
private void clearWorkingDirectory() throws IOException {
// This is a dangerous operation in a real system.
// For LLD, you can just print a message or delete synthetic files.
// Files.walk(repositoryRoot)
// .filter(Files::isRegularFile)
// .forEach(p -> { try { Files.delete(p); } catch (IOException e) { /* handle */ } });
System.out.println("Simulating clearing working directory.");
}
private void reconstructWorkingDirectory(Tree currentTree, Path currentPath) throws IOException {
for (Map.Entry<String, Blob> entry : currentTree.getFileEntries().entrySet()) {
Path filePath = currentPath.resolve(entry.getKey());
Files.createDirectories(filePath.getParent());
Files.write(filePath, entry.getValue().getContent());
}
for (Map.Entry<String, Tree> entry : currentTree.getDirectoryEntries().entrySet()) {
Path dirPath = currentPath.resolve(entry.getKey());
Files.createDirectories(dirPath);
reconstructWorkingDirectory(entry.getValue(), dirPath);
}
}
The serialize and deserialize methods are crucial. For a Java interview, you could use standard Java serialization, JSON (with a library like Jackson), or just ByteArrayOutputStream for byte[]. Mentioning these choices shows awareness.
Handling Branches
Branches are just pointers to commits. Your GitManager already has a Map<String, String> branches (name -> commit ID).
// In GitManager
private String currentBranch; // e.g., "main"
public void createBranch(String branchName) {
if (branches.containsKey(branchName)) {
throw new IllegalArgumentException("Branch " + branchName + " already exists.");
}
if (head == null) {
throw new IllegalStateException("Cannot create branch: no commits yet.");
}
branches.put(branchName, head); // New branch points to current HEAD
System.out.printf("Created branch '%s' pointing to commit %s%n", branchName, head);
}
public void switchBranch(String branchName) {
if (!branches.containsKey(branchName)) {
throw new IllegalArgumentException("Branch " + branchName + " does not exist.");
}
// Need to handle pending changes here, similar to real Git
// For LLD, you can assume a clean working directory or mention the need for stashing/merging.
// Also, perform a checkout to the branch's commit.
try {
checkout(branches.get(branchName));
this.currentBranch = branchName;
System.out.printf("Switched to branch '%s'%n", branchName);
} catch (IOException e) {
System.err.println("Failed to switch branch: " + e.getMessage());
}
}
private String getCurrentBranch() {
// This would typically be stored as part of the repo state, e.g., in .git/HEAD
// For simplicity, let's assume we track it in memory.
if (currentBranch == null && !branches.isEmpty()) {
return branches.keySet().iterator().next(); // Default to first branch if not set
}
return currentBranch != null ? currentBranch : "main"; // Default to "main" if nothing else
}
Caveats and Trade-offs (This is where you shine)
This simplified model has significant limitations. Acknowledging them shows maturity.
- No Diffs/Patching: Real Git doesn't store full files per commit unless absolutely necessary. It uses diffs and delta compression. Our model stores full
Blobs for every version, which is space-inefficient. Mention this. - No Merging/Conflict Resolution: This is a huge area skipped. Merging involves identifying a common ancestor, applying changes, and resolving conflicts. For an LLD, it's out of scope unless specifically asked as a follow-up.
- Performance: Reading files from disk, hashing content, and writing them back for every
addandcommitcan be slow for large repositories. Real Git uses a highly optimized index and object packing. - Error Handling & Edge Cases: What if a user tries to
adda non-existent file? What if the working directory has uncommitted changes duringcheckout? My code snippets only touch on this. In a full design, you'd layer robust error handling. - Concurrency: Multiple users pushing/pulling to the same remote. Our design is purely local.
- Persistence: My
ObjectStoreis in-memory. For persistence, you'd usejava.nio.fileto write objects to disk, similar to Git's.git/objectsstructure. Serializing objects (e.g., usingObjectOutputStreamor JSON) would be necessary for disk storage.
Remember to explicitly call out these simplifications. For example, "For this interview, I'm using an in-memory Map for the ObjectStore, but in a production system, this would persist objects to disk, perhaps using a content-addressable file system structure like Git's .git/objects directory." This demonstrates you understand the trade-offs.
Putting it into Practice: A Small Main Method
Let's quickly put together a main method to show how someone would use your GitManager.
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID; // For simple IDs, replace with SHA-1 in real design
// Placeholder serialization/deserialization methods for brevity
// In a real system, use JSON, Java Serialization, or custom byte formats.
interface Serializer {
byte[] serialize(Object obj);
<T> T deserialize(byte[] data, Class<T> clazz);
}
class SimpleSerializer implements Serializer {
// This is a minimal, non-production serialization for demonstration.
// For Blobs, it's just the content. For others, it's toString().getBytes().
@Override
public byte[] serialize(Object obj) {
if (obj instanceof Blob) return ((Blob) obj).getContent();
if (obj instanceof Commit) return ((Commit) obj).toString().getBytes(); // Needs proper fields
if (obj instanceof Tree) return ((Tree) obj).toString().getBytes(); // Needs proper fields
return null;
}
@Override
public <T> T deserialize(byte[] data, Class<T> clazz) {
// This is highly simplified and will likely break for complex objects.
// It's just to make the example compile.
if (clazz == Blob.class) return (T) new Blob(data); // Re-calculate ID if needed
return null; // Don't implement for Commit/Tree here for brevity
}
}
// Assume Blob, Tree, Commit, Index, ObjectStore, GitManager classes are defined above.
// Add a helper method to GitManager for serialization/deserialization internally
// Or pass a Serializer dependency.
// Simplified Blob (needs proper hash calculation)
class Blob {
private String id;
private byte[] content;
public Blob(byte[] content) { this.content = content; this.id = UUID.randomUUID().toString(); } // Simplified ID
public String getId() { return id; }
public byte[] getContent() { return content; }
private String calculateHash(byte[] content) { return UUID.randomUUID().toString(); } // Simplified
}
// Simplified Tree (needs proper hash calculation and entry management)
class Tree {
private String id;
private Map<String, Blob> fileEntries;
private Map<String, Tree> directoryEntries;
public Tree() {
this.fileEntries = new HashMap<>();
this.directoryEntries = new HashMap<>();
this.id = UUID.randomUUID().toString(); // Simplified ID
}
public void addFileEntry(String name, Blob blob) {
this.fileEntries.put(name, blob);
this.id = calculateHash(); // Re-calculate on change
}
// Add directory entry, etc.
public String getId() { return id; }
public Map<String, Blob> getFileEntries() { return fileEntries; }
public Map<String, Tree> getDirectoryEntries() { return directoryEntries; }
private String calculateHash() { return UUID.randomUUID().toString(); } // Simplified
}
// Simplified Commit (needs proper hash calculation)
class Commit {
private String id;
private String treeId;
private String parentId;
private String author;
private long timestamp;
private String message;
public Commit(String treeId, String parentId, String author, String message) {
this.treeId = treeId;
this.parentId = parentId;
this.author = author;
this.timestamp = System.currentTimeMillis();
this.message = message;
this.id = calculateHash(); // Simplified ID
}
public String getId() { return id; }
public String getTreeId() { return treeId; }
public String getParentId() { return parentId; }
public String getAuthor() { return author; }
public long getTimestamp() { return timestamp; }
public String getMessage() { return message; }
private String calculateHash() { return UUID.randomUUID().toString(); } // Simplified
@Override public String toString() { return String.format("Commit(id=%s, treeId=%s, parentId=%s, msg='%s')", id, treeId, parentId, message); }
}
// ObjectStore
class ObjectStore {
private Map<String, byte[]> objects = new HashMap<>();
private Serializer serializer = new SimpleSerializer(); // Use an actual serializer
public void putObject(String id, byte[] data) { objects.put(id, data); }
public byte[] getObject(String id) { return objects.get(id); }
// Helper methods to store/retrieve actual objects
public void putBlob(Blob blob) { putObject(blob.getId(), serializer.serialize(blob)); }
public Blob getBlob(String id) { return serializer.deserialize(getObject(id), Blob.class); }
public void putTree(Tree tree) { putObject(tree.getId(), serializer.serialize(tree)); }
public Tree getTree(String id) { return serializer.deserialize(getObject(id), Tree.class); } // Needs proper Tree deserialization
public void putCommit(Commit commit) { putObject(commit.getId(), serializer.serialize(commit)); }
public Commit getCommit(String id) { return serializer.deserialize(getObject(id), Commit.class); } // Needs proper Commit deserialization
}
// Index class (as defined before)
class Index {
private Map<String, String> stagedFiles;
public Index() { this.stagedFiles = new HashMap<>(); }
public void addFile(String filePath, String blobId) { stagedFiles.put(filePath, blobId); }
public Map<String, String> getStagedFiles() { return stagedFiles; }
public void clear() { stagedFiles.clear(); }
}
// GitManager (as defined before, with serialization/deserialization helpers)
// NOTE: I'm embedding serialize/deserialize directly here to keep the example self-contained,
// but passing a Serializer dependency is cleaner.
class GitManager {
private ObjectStore objectStore;
private Path repositoryRoot;
private String head;
private Map<String, String> branches;
private Index index;
private String currentBranch; // Which branch we are currently on
public GitManager(Path repoRoot) {
this.repositoryRoot = repoRoot;
this.objectStore = new ObjectStore();
this.branches = new HashMap<>();
this.head = null;
this.branches.put("main", null);
this.index = new Index();
this.currentBranch = "main";
}
public void init() { System.out.println("Initialized empty Git repository."); }
public void add(String filePath) throws IOException {
Path fullPath = repositoryRoot.resolve(filePath);
if (!Files.exists(fullPath) || Files.isDirectory(fullPath)) {
throw new IllegalArgumentException("File not found or is a directory: " + filePath);
}
byte[] content = Files.readAllBytes(fullPath);
Blob blob = new Blob(content);
objectStore.putBlob(blob);
index.addFile(filePath, blob.getId());
System.out.printf("Added file %s (blob ID: %s)%n", filePath, blob.getId());
}
public String commit(String message, String author) {
if (index.getStagedFiles().isEmpty()) {
System.out.println("No changes to commit. Staging area is empty.");
return null;
}
Tree rootTree = buildTreeFromIndex(index.getStagedFiles());
objectStore.putTree(rootTree); // Store the root tree
String parentCommitId = branches.get(getCurrentBranch());
Commit commit = new Commit(rootTree.getId(), parentCommitId, author, message);
objectStore.putCommit(commit);
head = commit.getId();
branches.put(getCurrentBranch(), commit.getId());
index.clear();
System.out.printf("Committed changes (ID: %s)%n", commit.getId());
return commit.getId();
}
private Tree buildTreeFromIndex(Map<String, String> stagedFiles) {
Tree root = new Tree();
for (Map.Entry<String, String> entry : stagedFiles.entrySet()) {
root.addFileEntry(entry.getKey(), objectStore.getBlob(entry.getValue()));
}
return root;
}
public void log() {
if (head == null) {
System.out.println("No commits yet.");
return;
}
String currentCommitId = head;
while (currentCommitId != null) {
Commit commit = objectStore.getCommit(currentCommitId);
if (commit == null) { // Handle case where commit might not be found (e.g., malformed history)
System.err.println("Error: Commit object not found for ID: " + currentCommitId);
break;
}
System.out.printf("Commit ID: %s%n", commit.getId());
System.out.printf("Author: %s%n", commit.getAuthor());
System.out.printf("Date: %s%n", new Date(commit.getTimestamp()));
System.out.printf("Message: %s%n%n", commit.getMessage());
currentCommitId = commit.getParentId();
}
}
public void checkout(String commitOrBranchName) throws IOException {
String targetCommitId = branches.getOrDefault(commitOrBranchName, commitOrBranchName); // Try branch then assume ID
Commit targetCommit = objectStore.getCommit(targetCommitId);
if (targetCommit == null) {
throw new IllegalArgumentException("Invalid commit ID or branch name: " + commitOrBranchName);
}
clearWorkingDirectory();
Tree rootTree = objectStore.getTree(targetCommit.getTreeId());
if (rootTree == null) {
throw new IllegalStateException("Root tree not found for commit " + targetCommitId);
}
reconstructWorkingDirectory(rootTree, repositoryRoot);
head = targetCommitId;
if (branches.containsKey(commitOrBranchName)) {
currentBranch = commitOrBranchName;
} else {
System.out.println("Checked out to commit " + targetCommitId + " (detached HEAD state).");
}
System.out.printf("Checked out to %s%n", commitOrBranchName);
}
private void clearWorkingDirectory() throws IOException {
System.out.println("Simulating clearing working directory.");
// In a real system, you'd iterate and delete files/dirs in repositoryRoot
}
private void reconstructWorkingDirectory(Tree currentTree, Path currentPath) throws IOException {
for (Map.Entry<String, Blob> entry : currentTree.getFileEntries().entrySet()) {
Path filePath = currentPath.resolve(entry.getKey());
Files.createDirectories(filePath.getParent());
Files.write(filePath, entry.getValue().getContent());
}
for (Map.Entry<String, Tree> entry : currentTree.getDirectoryEntries().entrySet()) {
Path dirPath = currentPath.resolve(entry.getKey());
Files.createDirectories(dirPath);
reconstructWorkingDirectory(entry.getValue(), dirPath);
}
}
public void createBranch(String branchName) {
if (branches.containsKey(branchName)) {
throw new IllegalArgumentException("Branch " + branchName + " already exists.");
}
if (head == null) {
throw new IllegalStateException("Cannot create branch: no commits yet.");
}
branches.put(branchName, head);
System.out.printf("Created branch '%s' pointing to commit %s%n", branchName, head);
}
public void switchBranch(String branchName) throws IOException {
if (!branches.containsKey(branchName)) {
throw new IllegalArgumentException("Branch " + branchName + " does not exist.");
}
checkout(branchName); // Checkout the head of the branch
this.currentBranch = branchName; // Update current branch tracker
System.out.printf("Switched to branch '%s'%n", branchName);
}
private String getCurrentBranch() {
return currentBranch;
}
}
public class GitClient {
public static void main(String[] args) throws IOException {
Path repoPath = Paths.get("./my_repo");
Files.createDirectories(repoPath);
GitManager git = new GitManager(repoPath);
git.init();
// Create some files
Files.write(repoPath.resolve("file1.txt"), "Version 1 of file1".getBytes());
Files.write(repoPath.resolve("file2.txt"), "Initial content of file2".getBytes());
git.add("file1.txt");
git.add("file2.txt");
String commit1Id = git.commit("Initial commit", "Alice");
Files.write(repoPath.resolve("file1.txt"), "Version 2 of file1".getBytes());
git.add("file1.txt");
String commit2Id = git.commit("Update file1", "Alice");
git.log();
git.createBranch("feature-a");
git.switchBranch("feature-a");
Files.write(repoPath.resolve("file3.txt"), "Content for feature-a".getBytes());
git.add("file3.txt");
git.commit("Add file3 on feature-a", "Bob");
git.log(); // Should show feature-a's history
git.switchBranch("main");
// file3.txt should disappear from working directory after checkout
System.out.println("Does file3.txt exist after switching to main? " + Files.exists(repoPath.resolve("file3.txt")));
git.checkout(commit1Id); // Go back to first commit
// file1.txt should be "Version 1 of file1"
System.out.println("Content of file1.txt after checkout commit1: " + new String(Files.readAllBytes(repoPath.resolve("file1.txt"))));
// Clean up
Files.walk(repoPath)
.sorted(java.util.Comparator.reverseOrder())
.map(Path::toFile)
.forEach(java.io.File::delete);
}
}
This main method demonstrates the flow and interaction. It shows you've thought about how a user would interact with your system.
Performance Considerations: Scaling Up
You've built the core. Now, let's talk about making it better, because interviewers love to push on scale.
For performance, hashing content (e.g., SHA-1) can be CPU-intensive, especially for large files. Real Git uses a combination of techniques:
- Loose objects: Initially, objects are stored as individual files.
- Packfiles: Over time, Git "packs" these loose objects into a single, compressed file (
.git/objects/pack/pack-*.pack) and builds an index (.git/objects/pack/pack-*.idx) for fast lookup. This significantly reduces disk space and I/O. - Delta Compression: Instead of storing full
Blobs for every version, Git often stores only the differences (deltas) between a new version and an older, similar version. This is a massive space saver.
Mentioning these points shows a deeper understanding of version control systems beyond just the basic LLD. You don't need to implement them, just explain their relevance for a production-grade system.
Conclusion: What They're Really Looking For
An LLD interview isn't about writing runnable, bug-free code for a complex system in an hour. It's about demonstrating:
- Problem Decomposition: Can you break down a big problem into manageable, object-oriented components?
- Core Concepts: Do you understand the fundamental principles (e.g., Git's snapshot model, content-addressable storage)?
- Object-Oriented Design: Proper use of classes, encapsulation, relationships, and responsibilities.
- Trade-offs: Can you identify limitations, discuss design choices, and suggest improvements?
- Communication: Can you articulate your thought process clearly and defend your design?
Focus on these, and you'll do fine. Don't get lost in the weeds of perfect syntax or implementing every Git feature. Nail the foundation.
Ready to Ace Your Next Interview?
Practice with AI-powered mock interviews tailored to your target role and company. Start Practicing for Free | Explore Interview Prep
