CodeCrafter's Build your own Git
This journal documents errors made, lessons learned, troubleshooting steps taken, and resources used across all completed stages of the CodeCrafters "Build Your Own Git" challenge in Python.
Stage 1: Read a Blob Object (git cat-file)¶
Errors Made¶
Jumping ahead mentally — Before writing any code, assumed this stage was about commits and who committed code. It was only about reading blobs. Lesson: read the stage carefully and solve only what's in front of you.
Wrong index for the hash — Initially assumed sys.argv worked differently. Had to write out the full list to figure out the hash was at index [3], not somewhere else.
Splitting on the wrong character — Tried to split on \ instead of \0. The null byte is \x00 in Python binary mode, not just a backslash.
Using print() instead of sys.stdout.write() — print() appends a newline which broke the tester output. Had to switch to sys.stdout.write().
What I Learned¶
sys.argvis a list of everything typed on the command line. Index[0]is always the script name, so real arguments start at[1].- Git splits object hashes into a 2-character folder and 38-character filename to prevent inode exhaustion (too many files in one directory).
- Compressed files must be opened in binary mode
"rb", not text mode"r". - After decompressing a blob, the format is
blob <size>\0<content>. The null byte\x00separates the header from the content. - Binary data must be decoded with
.decode()before printing as text.
Troubleshooting Steps¶
- Wrote out
sys.argvas a full list to find the correct index for the hash. - Confirmed the null byte separator by re-reading the blob format in the instructions.
- Switched from
print()tosys.stdout.write()after reading the notes section warning about newlines.
Resources Used¶
- CodeCrafters stage comments had links to resources
- Python
zlibdocumentation (forwbitsparameter, determined default was sufficient) sys.argvdocumentation
Code¶
elif command == "cat-file":
hash = sys.argv[3]
folderName = hash[:2] # spilting the hash, the first two is the folder name and after the first two is the file name
fileName = hash[2:] # git splits the hash to avoid having millions of files in a single directory (prevents inode exhaustion)
filePath = f".git/objects/{folderName}/{fileName}" # used an an f string to simplify concatenation, this is building out the file path.
with open(filePath, "rb") as file:
data = file.read() # opens up the file to read it in binary because the file is compressed
alibDecompress = zlib.decompress(data) # decompresses the file and stores it in a variable
splitBinary = alibDecompress.split(b"\x00") # git adds a separator or null byte to figure out what is the header and what is the content of the file so we need to split it out.
finalOutput = splitBinary[1].decode() # giving a variable and decoding the binary that was split from the file.
sys.stdout.write(finalOutput) # used sys.stdout.write() instead of print() because print() appends a new line by default which throws off the tester.
Stage 2: Create a Blob Object (git hash-object)¶
Errors Made¶
sys.arg typo — Wrote sys.arg[3] instead of sys.argv[3]. Python threw an AttributeError.
Wrong slice index for filename — Used hash[3:] instead of hash[2:] when splitting the hash into folder and filename.
Using f-string to combine bytes — Tried f"{encodingHeader}{content}" which creates a string, not bytes. You cannot mix strings and bytes in Python, it throws a TypeError.
Using wrong variable name — Used data instead of combining when hashing and compressing. Variable names must be consistent.
os.mkdir crashing on existing folders — If the folder already exists, os.mkdir throws a FileExistsError. Fixed by switching to os.makedirs(path, exist_ok=True).
sys.stdout.write inside the with open block — Wrote the output inside the file-writing block instead of after it. Moved it outside.
What I Learned¶
- This stage is the reverse of
cat-file, instead of reading and decompressing, you build, compress and write. - The SHA-1 hash must be computed over the header + content combined, not just the content alone. Two files with the same content but different headers must produce different hashes.
- Headers must be encoded to bytes with
.encode()before combining with binary content. os.makedirswithexist_ok=Trueis safer thanos.mkdirit won't crash if the folder already exists.- The SHA-1 hash is computed on uncompressed data. Compression happens after hashing.
hashlib.sha1(data).hexdigest()gives a readable 40-character hex string.
Troubleshooting Steps¶
- Wrote out the full
sys.argvlist to confirm the filename was at index[3]. - Traced through the variable names to find where
datawas used incorrectly instead ofcombining. - Tested
os.makedirswithexist_ok=Trueto prevent crash on duplicate folder creation.
Resources Used¶
- CodeCrafters stage comments had links to resources
- Python
hashlibdocumentation - Python
os.makedirsdocumentation
Code¶
def createBlob(fileName): # putting the creating blob from elif command == "hash-object" into a function.
with open(fileName, "rb") as file:
content = file.read() # opens the file to read it since it's in binary I have to specify to open it as binary, give a variable to store the file contents
header = f"blob {len(content)}\x00" # created the header of the file using an f string and encoding it, the header needs to know the length of the cotent so git can know and use a null separator. used .encode() because content is bytes and going to combine them
encodingHeader = header.encode() # encoding the header
combining = encodingHeader + content # combining the encoded header with the content to hash them together. the hash needs to represent the complete blob object not just the file contents. If two files had the same content but different headers, Git needs to be able to tell them apart. The header includes the type and size, so hashing everything together makes the hash a unique fingerprint for the entire object
sha1Hash = hashlib.sha1(combining).hexdigest() # sha1 hash of the content and header and converts the hash into a readable 40-character string of hexadecimal characters.
alibCompress = zlib.compress(combining) # compresses the header and content to save disk space
folderName = sha1Hash[:2] # gets the folder name which is the first two characters of the hash
fileName2 = sha1Hash[2:] # gets the file name which is the hash after the two characters
os.makedirs( f".git/objects/{folderName}/", exist_ok=True) # makes a directory based on the foldername, if the folder doesn't exist yet, Python will error when you try to write a file into it. You have to create the folder first before you can put anything inside it. Changed it to os.makedirs() with exist_ok=True so it doesn't complain if it already exists
filePath = f".git/objects/{folderName}/{fileName2}" # sets a variable that creates the file path of the object
with open(filePath, "wb") as file:
data = file.write(alibCompress) # writes the compressed binary file to the specified file path
return sha1Hash
Stage 3: Read a Tree Object (git ls-tree)¶
Errors Made¶
Opening .git/objects directly — Tried to open the directory itself instead of building the full file path from the hash.
Using wrong sys.argv index — Assumed the hash was at sys.argv[4]. Writing out the full list confirmed it was at sys.argv[3].
Splitting on wrong type — Used '\0' (a string) instead of b'\x00' (bytes) when splitting binary data. Python throws a TypeError when mixing types.
Using splitBinary[7:] — Misunderstood list indexing. Splitting on \x00 gives only 2 parts: index [0] is the header, index [1] is the content.
Using return inside the while loop — return name exits the function after the first entry. Should use print(name) to output each name and continue looping.
position =+ 20 outside the loop — This only ran once and was outside the while loop. Needed to be position = positionOfnull + 1 + 20 inside the loop.
What I Learned¶
- Tree objects have multiple entries, each with a mode, name, null byte, and raw 20-byte SHA.
- The SHA in tree objects is raw bytes (not hex), so you cannot read the file as text.
- A
whileloop with manual position tracking is needed to parse binary formats where entries have variable length. entries.index(b"\x00", position)finds the next null byte starting from a given position.- After the null byte, you skip exactly 20 bytes for the SHA before the next entry starts.
split(" ", 1)splits on the first space only, giving["100644", "filename"]. Index[1]is the name.
Troubleshooting Steps¶
- Wrote out the full
sys.argvlist to confirm the correct index. - Traced through the binary format manually to understand why
split()alone wasn't enough. - Added
position = positionOfnull + 1 + 20to correctly advance through entries. - Replaced
returnwithprint()to stop the function from exiting after the first entry.
Resources Used¶
- Git tree object format documentation
Code¶
elif command == "ls-tree":
sha1Hash = sys.argv[3] # Get the tree sha1 hash from sys.argv[3]
folderName = sha1Hash[:2]
fileName = sha1Hash[2:]
filePath = f".git/objects/{folderName}/{fileName}" # Build the file path (same as before)
with open(filePath, "rb") as file:
contents = file.read()
alibDecompress = zlib.decompress(contents) # Read and decompress the file
splitFile = alibDecompress.split(b"\x00", 1) # Strip the header by splitting on \0
entries = splitFile[1]
position = 0
while position < len(entries):
positionOfnull = entries.index(b"\x00", position) # find the position of \0
modeAndname = entries[position:positionOfnull] # extract mode and name
Output = modeAndname.decode() # decode
finalOutput = Output.split(" ", 1) # split the space
name = finalOutput[1] # get the name which is index 1 and set the variable
print(name)
position = positionOfnull + 1 + 20 # skip 20 bytes for SHA and move to the next entry
Stage 4: Write a Tree Object (git write-tree)¶
Errors Made¶
Iterating over .git/objects instead of the working directory — The working directory "." is where actual files live. .git/objects is internal Git storage.
os.listdir.sort(directory) — invalid syntax — sort() is a method on a list, not on os.listdir. Had to store the list first, then call .sort() on it.
entries.append(sha1Hash) — appending only the hash — Each entry needs three things: mode, name, and hash. Should append a tuple ("100644", x, sha1Hash).
Hardcoding "3b" in os.mkdir — Used the example hash from the instructions instead of the actual folderName variable.
treeContent = b"" inside the loop — This reset the content to empty on every iteration. Moved it before the loop.
treeContent =+ ... — invalid operator — Python uses += to add to an existing variable, not =+.
combiningHeadercontent = treeHeader + treeContent — treeHeader is a string and treeContent is bytes. Must encode the header first.
from unicodedata import combining — An accidental import that conflicted with the variable name combining. Deleted it.
elif command == "write-tree" had wrong body — Was trying to read sys.argv[3] but write-tree takes no arguments. Should just call writeTree(".").
What I Learned¶
- Recursion means calling a function inside itself.
writeTreecalls itself when it encounters a subdirectory. os.path.isfile(path)andos.path.isdir(path)returnTrueorFalseuse them directly inifstatements.os.path.join(directory, x)builds a full path from a directory and a filename.- Tree entries must be tuples with
(mode, name, sha1Hash)to store all three pieces of data. - File mode is
"100644"for regular files and"40000"for directories. bytes.fromhex(sha1Hash)converts a hex string to raw 20 bytes for writing into the tree.- The
write-treecommand takes no arguments, it writes the current working directory as a tree. - Refactoring
hash-objectlogic into acreateBlob()function made it reusable across stages.
Troubleshooting Steps¶
- Confirmed the working directory
"."is the correct starting point, not.git/objects. - Fixed
os.listdir.sort()by separating into two lines: store the list, then sort it. - Changed
entries.append(sha1Hash)toentries.append(("100644", x, sha1Hash)). - Moved
treeContent = b""before the loop so it doesn't reset each iteration. - Removed accidental
from unicodedata import combiningimport.
Resources Used¶
- Ben Hoyt's pygit article: https://benhoyt.com/writings/pygit/#committing
- Python
os.pathdocumentation
Code¶
def writeTree(directory): # created a function to create the tree which gets the current directory of the user as the argument
listCurrentdirectory = os.listdir(directory) # set a variable and lists all files and directories (folders) in the current directory
listCurrentdirectory.sort() # sorts them alphabetically
entries = []
for x in listCurrentdirectory:
fullPath = os.path.join(directory, x)
if x == ".git":
continue
if os.path.isfile(fullPath): # checks to see if it's a file
sha1Hash = createBlob(fullPath)
entries.append(("100644", x, sha1Hash))
if os.path.isdir(fullPath): # checks to see if it's a directory (folder)
sha1Hash = writeTree(fullPath)
entries.append(("40000", x, sha1Hash))
treeContent = b""
for mode, name, sha1Hash in entries:
convert = bytes.fromhex(sha1Hash)
treeContent += f"{mode} {name}\0".encode() + convert
treeHeader = f"tree {len(treeContent)}\0"
encodingHeader = treeHeader.encode()
combiningHeadercontent = encodingHeader + treeContent
sha1Hash = hashlib.sha1(combiningHeadercontent).hexdigest()
alibCompress = zlib.compress(combiningHeadercontent)
folderName = sha1Hash[:2]
fileName2 = sha1Hash[2:]
os.makedirs(f".git/objects/{folderName}/", exist_ok=True)
filePath = f".git/objects/{folderName}/{fileName2}"
with open(filePath, "wb") as file:
data = file.write(alibCompress)
return sha1Hash
Stage 5: Create a Commit Object (git commit-tree)¶
Errors Made¶
return sha1Hash inside elif — return sends a value back from a function to whatever called it. But elif command == "commit-tree": is not a function — it's just a block inside main(). Nothing is calling it and waiting for a value back. Using return here just exits main() entirely. The fix is sys.stdout.write(sha1Hash), which prints the hash to the terminal where the tester can read it.
encodingcommitString = commitString without calling .encode() — Named the variable as if it was encoded but never actually encoded it. So combining bytes header + string content threw a TypeError.
Putting \" at the end of commit string lines — Each line in the commit content ends with \n (newline), not \" (a quote character).
Missing blank line before the commit message — The commit format requires an empty line between the metadata and the message. Missing it would produce a malformed commit object.
message {sys.argv[6]} — The word "message" doesn't appear in the actual commit file format. The message is just the raw string on its own line.
What I Learned¶
- The commit object follows the same build-hash-compress-write pattern as blobs and trees. The only difference is what the content looks like.
- Commit content is plain text with specific fields in a specific order:
tree,parent,author,committer, blank line, then the message. Each line ends with\n. - The tree and parent SHAs in a commit are 40-character hex strings, unlike in tree objects where they're 20 raw bytes.
- Author and committer can be hardcoded for this challenge. In a real Git implementation they'd come from your config.
returnexits a function and gives a value back to whoever called it. Inside anelifblock inmain(), there's no caller waiting — just usesys.stdout.write()to print the result.commitString.encode()converts the plain text commit content to bytes before combining with the encoded header. Same reason as always: can't mix strings and bytes in Python.
Troubleshooting Steps¶
- Wrote out the full
sys.argvlist to confirm tree sha at[2], parent sha at[4], message at[6]. - Fixed
return sha1Hashtosys.stdout.write(sha1Hash)after realizingelifblocks can't return values. - Added
.encode()tocommitStringafter theTypeErrorfrom combining a string with bytes. - Added the blank
f"\n"line between committer and message after re-reading the commit format.
Resources Used¶
- CodeCrafters stage instructions (commit object format)
- Stack Overflow: https://stackoverflow.com/questions/22968856/what-is-the-file-format-of-a-git-commit-object-data-structure
Code¶
elif command == "commit-tree":
commitString = (f"tree {sys.argv[2]}\n"
f"parent {sys.argv[4]}\n"
f"author John Doe <[email protected]> 1234567890 +0000\n"
f"committer John Doe <[email protected]> 1234567890 +0000\n"
f"\n"
f"{sys.argv[6]}\n")
header = f"commit {len(commitString)}\x00"
encodingHeader = header.encode()
encodingcommitString = commitString.encode()
combining = encodingHeader + encodingcommitString
sha1Hash = hashlib.sha1(combining).hexdigest()
alibCompress = zlib.compress(combining)
folderName = sha1Hash[:2]
fileName2 = sha1Hash[2:]
os.makedirs(f".git/objects/{folderName}/", exist_ok=True)
filePath = f".git/objects/{folderName}/{fileName2}"
with open(filePath, "wb") as file:
data = file.write(alibCompress)
sys.stdout.write(sha1Hash)
Running Themes & Lessons¶
Read the stage carefully before coding. The first instinct was always to jump ahead. Each stage is one specific thing.
Write out sys.argv as a list every time. It removes all guesswork about which index holds what.
Bytes and strings are different types in Python. You cannot mix them. Always encode strings to bytes before combining with binary data.
os.makedirs with exist_ok=True is always safer than os.mkdir. Use it whenever creating directories in Git objects.
Build pseudocode first. Every stage went smoother when the steps were written in plain English before touching the code.
Variable names must be consistent. Several bugs came from using the wrong variable name.
Position tracking in binary data requires a while loop. When entries have variable length followed by fixed-length raw bytes, split() alone is not enough.