Noona's orders: Golu, this is where we build a Git-lite version to learn how Git actually works under the hood. No dependencies, no fancy libraries. Pure Node.js built-ins (fs, path, crypto, zlib).
Follow this zero-decision-fatigue daily sub-task checklist to implement Git-Lite.
- Create Directory Structures:
- Verify if
.git-litealready exists. If yes, print that it's already initialized. - If not, create
.git-lite/directory. - Create
.git-lite/objects/(for storing compressed blobs, trees, commits). - Create
.git-lite/refs/heads/(for holding branch pointer files).
- Verify if
- Write Default HEAD File:
- Write a file
.git-lite/HEADcontaining:ref: refs/heads/main\n.
- Write a file
- Output Logging:
- Print confirmation:
Initialized empty Git-Lite repository in <absolute_path_to_.git-lite>.
- Print confirmation:
- File Reading:
- Read input file as raw binary/Buffer using
fs.readFileSync(filePath).
- Read input file as raw binary/Buffer using
- Blob Header Serialization:
- Format:
blob <size-in-bytes>\0<file-contents>. - Remember, the separator is a strict null byte (
\0/0x00). Concatenate buffers properly so binary files don't break.
- Format:
- SHA-1 Calculation:
- Hash the serialized buffer using Node's
crypto.createHash('sha1').update(buffer).digest('hex'). - Print the full 40-character hexadecimal SHA-1 string to stdout.
- Hash the serialized buffer using Node's
- Object Writing (
-wflag):- If
-wis active:- Get the first 2 characters of the hash as the folder name (e.g.,
2a). - Get the remaining 38 characters as the filename (e.g.,
3b4c5d...). - Construct the path:
.git-lite/objects/2a/3b4c5d.... - Create the sub-folder if it doesn't exist.
- Compress the entire serialized buffer using
zlib.deflateSync(serializedBuffer). - Write the compressed bytes to the file path.
- Get the first 2 characters of the hash as the folder name (e.g.,
- If
- Hash Parsing & File Path construction:
- Retrieve the 40-character hex hash from arguments.
- Split it into directory (first 2 chars) and filename (remaining 38 chars).
- Construct the path:
.git-lite/objects/<dir>/<filename>. - Handle file-not-found error gracefully (print error message and exit with 1).
- Decompression:
- Read the compressed object file using
fs.readFileSync. - Decompress using
zlib.inflateSync(compressedBuffer).
- Read the compressed object file using
- Header Parsing & Output Payload:
- Find the index of the null byte (
\0/0x00) in the decompressed buffer. - Parse the header (e.g.,
blob <size>) and print contents after the null byte to stdout. - If
-pis specified, write the raw content buffer toprocess.stdout.write(contentBuffer).
- Find the index of the null byte (
- Directory Traversal (DFS):
- Implement a recursive depth-first search (DFS) traversal function of the workspace.
- Ignore
.git-lite,node_modules,.git,dist, and any build directories to keep the tree clean.
- Entry Sorting & Serialization:
- For each directory, list all entries sorted alphabetically (standard Git sorts entries by name).
- For each entry:
- If it's a file: read its contents, construct the blob object (header + contents), hash it, compress it, and write it to
.git-lite/objects/(similar tohash-object -w). Store its mode as100644. - If it's a directory: recursively run
write-treeon it to write the sub-tree and get its 40-character hex hash. Store its mode as40000.
- If it's a file: read its contents, construct the blob object (header + contents), hash it, compress it, and write it to
- Tree Object Formatting:
- A tree object contains a list of directory entries. Format each entry as:
<mode> <name>\0<binary-sha1>(Note:<binary-sha1>is the 20-byte binary representation of the 40-character hex hash, i.e.,Buffer.from(hash, 'hex')).
- Concatenate all serialized entries into a single Buffer.
- Prepend the tree header:
tree <size-in-bytes>\0.
- A tree object contains a list of directory entries. Format each entry as:
- Write Tree & Return Hash:
- Calculate the SHA-1 hash of the full tree buffer.
- Compress using
zlib.deflateSyncand write to.git-lite/objects/under the appropriate sub-folder. - Print the 40-character hex hash of the tree to stdout.
- Write Tree State:
- Run
writeTree()to generate and retrieve the current root workspacetree-hash.
- Run
- Resolve Parent Hash:
- Read
.git-lite/HEADto find the current active branch ref path (e.g.refs/heads/main). - Read the resolved ref file (e.g.
.git-lite/refs/heads/main). If it exists, read the 40-character commit hash within it as theparent-hash. If the file doesn't exist, skip the parent property.
- Read
- Serialize Commit Object:
- Format the commit content as:
tree <tree-hash> parent <parent-hash> (if exists) author Golu <golu@example.com> <timestamp-in-seconds> +0530 committer Golu <golu@example.com> <timestamp-in-seconds> +0530 <commit-message> - Prepend the header:
commit <size-in-bytes>\0.
- Format the commit content as:
- Write Commit to CAS:
- Compute SHA-1, compress it using
zlib.deflateSync, and write to.git-lite/objects/under the calculated SHA-1 path.
- Compute SHA-1, compress it using
- Update Branch Pointer:
- Write the newly generated commit hash to the resolved branch ref file (e.g.
.git-lite/refs/heads/main) ending with a newline. - Print the new commit hash to stdout.
- Write the newly generated commit hash to the resolved branch ref file (e.g.
- Null Byte Trap: Don't just concatenate strings. If Golu passes a binary file (like a png or zip), string concatenation will corrupt it. Use
Buffer.concat([Buffer.from("blob <size>\0"), fileBuffer]). - ESM Import Path: Since we configured
"type": "module", remember to use relative file imports with.jsextensions (e.g.,import { init } from './commands/init.js'). - Binary SHA-1 in Trees: In standard Git tree files, the SHA-1 is serialized as a raw 20-byte binary buffer, not its 40-character hex string. Use
Buffer.from(hash, 'hex')to convert the hex string before writing it to the tree entry. - Execution Mode: Let's keep the codebase modular. Define functions clearly and write robust DFS traversals. Keep testing as you code!