Version Control#
1. What is Version Control?#
Whether you are writing a report, designing a presentation, or building software, managing changes over time is a universal challenge across education, business, and personal projects.
Imagine working on a document and deciding to rewrite a major section. Fearing you might lose valuable details, you save a new copy named report_v1.docx before making changes. A few weeks and dozens of revisions later, your folder is cluttered with report_v2.docx, report_final.docx, and report_final_v2.docx. Finding a specific paragraph from an earlier revision requires opening and scanning every single file.
Collaborating with others makes this even more chaotic. When multiple people modify the same document simultaneously, file conflicts inevitably happen. Without a clear system, tracking down who changed what—or rolling back unwanted edits—becomes a time-consuming headache.
To solve this, modern tools like Microsoft 365 include built-in version control. By going to File → Version History, it can view and restore previous versions of a document. In cloud apps, this process is fully managed server-side. However, dedicated version control systems (like Git) take this concept much further, offering far greater control over complex projects.
2. Why Use Version Control?#
Version control provides three main benefits:
- Tracking: Automatically keep a clear record of every change, including who made it and why.
- Restoration: Safely revert files or whole projects to a previous working state whenever something breaks.
- Collaboration: Seamlessly merge contributions from different people without overwriting anyone’s work.
3. How Version Control Works#
There are two main approaches to managing versions:
- Centralized Systems (e.g., SVN): A single central server stores all file versions. Team members check out files from this central location, make edits, and commit them back.
- Distributed Systems (e.g., Git): Every contributor has a complete copy of the repository—including its entire revision history—on their local machine. This allows offline work, fast local commits, and safer branching strategies before merging changes back to a main repository.
Git Basics#
Git is a distributed version control system that tracks changes in project files. It enables take snapshots of code, revert to previous states, and collaborate seamlessly with remote repositories.
Core Workflow Commands#
Below is the fundamental command-line workflow every beginner uses when managing a Git project:
# Initialize a new local Git repository in the current directory
git init
# Check the status of working tree (staged, unstaged, and untracked files)
git status
# Stage specific changes for the next commit
git add example.txt
# Stage all new, modified, and deleted files in the current directory
git add .
# Save staged snapshot to the history with a descriptive log message
git commit -m "Add initial project files"
# Connect local repository to a remote server (e.g., GitHub, Codeberg)
git remote add origin [https://example.com/username/repo.git](https://example.com/username/repo.git)
# Fetch the latest metadata and branches from the remote repository
git fetch
# Download and merge the latest remote changes into active branch
git pull origin main
# Upload local commits to the remote branch
git push origin mainUnderstanding the Git Staging Lifecycle#
To work comfortably with Git, it helps to understand how files move through its primary areas:
graph TD
A["Working Directory"] -- "git add" --> B["Staging Area (Index)"]
B -- "git commit" --> C["Local Repository"]
C -- "git push" --> D["Remote Repository"]
- Working Directory: Where actively edit and create project files.
- Staging Area (Index): A preparation zone where collect and organize the specific changes that package into next commit.
- Local Repository: local database (stored inside the hidden .git folder) that permanently records all committed snapshots.
- Remote Repository: A server-hosted copy (such as GitHub or Codeberg) used for cloud backups and team collaboration.
How Does Git Work?#
At its core, Git is an content-addressable key-value store built around three primary object types: Blobs, Trees, and Commits. All objects are stored inside the hidden .git/objects directory and identified by a 40-character SHA-1 hash calculated from their contents.
graph TD
C["Commit Object \n (Author, Message, Parent)"] --> T["Tree Object (Root Dir)"]
T --> B1["Blob Object \n (file1.txt content)"]
T --> T2["Tree Object (Subfolder)"]
T2 --> B2["Blob Object \n (file2.txt content)"]
1. Blob (Binary Large Object)#
A Blob stores only the raw file content—it does not store filenames, directory paths, creation dates, or file permissions.
- Content-Based Hashing: The filename of a blob in .git/objects is generated directly from the hash of its content.
- Deduplication: If two files in project have identical contents (even under different names or locations), Git stores only a single blob object to save disk space.
2. Tree Object#
A Tree represents a directory structure at a given point in time. It solves the limitation of blobs by pairing blob hashes with their actual filenames, file permissions, and directory paths.
- Hierarchical Structure: A tree object contains pointers (hashes) to both blobs (individual files) and other child trees (subdirectories).
- Directory Snapshot: Navigating a tree object is equivalent to navigating folders in a file system. A root tree object effectively represents a full snapshot of project’s directory.
3. Commit Object#
A Commit represents a saved snapshot of project’s history. It binds a root tree object with metadata:
- Author & Committer Info: Who created the change and when.
- Commit Message: The description of why the change was made.
- Parent Pointer: A reference to the preceding commit’s SHA-1 hash (the first commit in a repository has no parent; merge commits have two or more parents).
By linking parent hashes together, commit objects form the direct acyclic graph (DAG) that makes up repository’s full commit history.
How Git Handles File Changes Across Versions#
Git avoids unnecessary overhead through content immutability, object re-use, and packfile delta compression.
graph TD
subgraph Loose Objects
B1["v1 Blob (zlib compressed)"]
B2["v2 Blob (zlib compressed)"]
end
subgraph Packfile Optimization [git gc / git push]
PF["Packfile (.pack)"]
PF --> Base["Base Version (Full File)"]
PF --> Delta["Delta (Diffs Only)"]
end
B1 --> PF
B2 --> PF
- Immutable Blobs and Deduplication
- Once created inside .git/objects, a blob is immutable—it is never modified or updated in place.
- Zero-Cost Unchanged Files: If edit 1 file out of 100 and make a new commit, Git creates only one new blob for the modified file. The new commit’s tree simply points back to the existing 99 blob hashes from previous commits.
- Content Deduplication: If duplicate a file or create a file with identical content anywhere in the project tree, Git creates only a single blob object.
- Once created inside .git/objects, a blob is immutable—it is never modified or updated in place.
- Loose Objects Stage
- When stage or commit files using git add, Git initially writes them as individual loose objects:
- Independent Compression: Every blob, tree, and commit is compressed independently using the zlib compression algorithm.
- Fast Write Speeds: Creating loose objects requires minimal CPU overhead, making staging and committing locally feel instantaneous.
- When stage or commit files using git add, Git initially writes them as individual loose objects:
- Packfiles and Delta Compression
- Over time, accumulating thousands of individual loose files can strain file systems and degrade performance. Git solves this automatically via garbage collection (git gc) or during network transfers (git push):
- Similarity Detection: Git scans loose objects to find files that share identical paths, similar sizes, or highly overlapping content.
- Packfiles (.pack): Hundreds or thousands of loose objects are combined into a single, highly indexable archive file.
- Delta Compression: Instead of storing two nearly identical 10 MB files, Git keeps one complete version as a base (usually the most recent version for faster checkout speeds) and stores only the delta (the precise bytes changed) for earlier revisions.
- Over time, accumulating thousands of individual loose files can strain file systems and degrade performance. Git solves this automatically via garbage collection (git gc) or during network transfers (git push):
Why Git Stores the Newest File as the Full Base#
For instance, have 10 commits while each commit change little bit of the document.
Local Editing (Loose Objects Stage)
As create those 10 commits one by one:
- Git creates 10 separate blob objects in
.git/objects/. - Each blob contains the full text of that file at that specific commit (compressed with
zlib).
- Git creates 10 separate blob objects in
Packing (
git gcorgit push)When Git decides to clean up disk space or prepare files for remote transmission:
- Git groups the 10 versions of Markdown file together.
- It selects Commit #10 (the newest version) to remain as the Full Base File.
- For Commits #1 through #9, Git calculates the exact byte differences (deltas) relative to the newer versions.
- It packs the 1 base file + 9 deltas into a single
.packfile and deletes the original 10 loose blobs.
- Fast Checkout for Current Code: Developers check out
main branchor the latest commit 99% of the time. By making the newest commit the complete, un-diffed base file, Git can check out current files instantly without having to re-apply 100 historical diffs sequentially. - Reconstructing Past History: If view Commit #1, Git starts with the base file from Commit #10 and applies the stored deltas backwards in time to reconstruct the older file version.
Why Git Should Avoid Tracking Large and Binary Files#
Because Git objects are immutable, every unique file state is stored permanently as a separate blob object inside .git/objects/. While text files benefit immensely from compression and delta encoding, large or binary files cause severe repository bloat and performance degradation.
1. Large Files Inflate Repository Size#
When a large file (like a 500 MB database backup or video file) is updated across multiple commits, Git saves a new full blob for each version in the loose objects stage.
Even after packfile compression, cloning or fetching the repository requires downloading the entire historical footprint of every large file version ever committed. This results in:
- Storage Bloat: A project with a few megabytes of source code can easily swell to tens of gigabytes.
- Slow Operations: Clone, fetch, and pull commands take significantly longer due to massive network transfers and high disk memory consumption.
2. Binary Files Cannot Be Efficiently Diffed#
Text files (like code or Markdown) change line-by-line, allowing Git to calculate granular byte-level deltas. Binary files (like compiled binaries, compressed ZIP archives, or images), however, present distinct challenges:
- Inneficient Delta Compression: Even a minor edit—such as resizing an image or changing a single metadata tag—completely alters the underlying binary stream. Git cannot compute meaningful diffs between versions, forcing it to store almost entirely new full-sized objects instead of small deltas.
- Meaningless Diffs: Line-based diff tools cannot render human-readable changes for binary files, making version-by-version review useless.
Managing Big Files with Git LFS (Large File Storage)#
To prevent repository bloat, Git created Git LFS (Large File Storage), an open-source extension that changes how large assets are handled.
graph TD
subgraph Git Repository [.git]
P["Pointer File (130 bytes) \n SHA-256 + File Size"]
end
subgraph LFS Server / Cloud
A["Actual Large File / Binary \n (Video, Audio, Asset)"]
end
P -- "Downloads on checkout" --> A
- Pointer Files: Instead of committing the actual binary or large file into the Git object store, Git LFS replaces the file in repository with a tiny pointer file (about 130 bytes) containing the file’s SHA-256 hash and size.
- External Storage: The actual large binary payload is transferred via HTTPS and stored on a separate LFS server (such as GitHub LFS or Codeberg LFS).
- On-Demand Checkout: When clone or switch branches, Git LFS downloads only the specific version of the large file required for active working directory, keeping local disk usage low and clone speeds fast.
What is a Git Submodule?#
A Git Submodule allows keep a Git repository as a subdirectory of another Git repository. It lets clone another repository into project while keeping its commit history separate and isolated from main project.
At its core, a submodule is simply a pointer to a specific commit in another repository.
graph TD
subgraph Main Repository [Main Project Repo]
C["Commit #A"] --> T["Tree (Root)"]
T --> F1["cmd/main.go"]
T --> S[".gitmodules & Submodule Pointer"]
end
subgraph External Repository [Submodule Repo]
S -- "points to exact commit" --> SC["Commit 1D69 (Fixed Tag/SHA)"]
SC --> ST["Submodule Tree"]
end
CI/CD (Continuous Integration & Continuous Delivery / Deployment)#
As software development scales across teams, manually building, testing, and deploying code becomes a bottleneck. CI/CD introduces automation into the software release lifecycle, ensuring that code changes are continuously validated and safely delivered to production.
graph TD
Dev["Developer Push to Repository"] --> CI["1. Continuous Integration \n (Build & Unit Test)"]
CI --> CD_Del["2. Continuous Delivery \n (Staging / Test Env)"]
CD_Del --> CD_Dep["3. Continuous Deployment \n (Automated Production)"]
Continuous Integration (CI)
Continuous Integration is the practice where developers frequently commit and push their code changes into a shared central repository (often multiple times a day).
- Automated Builds & Testing: Every time code is pushed, an automated CI runner (such as GitHub Actions or GitLab CI) automatically triggers a build and runs unit tests.
- Early Bug Detection: Catching integration bugs and syntax errors immediately after a push prevents “integration hell” when merging features later.
- Code Quality Assurance: Helps maintain steady code quality across team members through automated linters and test suites.
Continuous Delivery (CD)
Once code successfully passes all integration tests in the CI stage, Continuous Delivery automatically packages and deploys the build artifact to testing, staging, or QA environments.
- Release Readiness: Ensures that the main branch is always in a release-Manual Approval Gate: Production deployments in Continuous Delivery still require a manual sign-off or trigger from a team member or release engineer before pushing to live users.
Continuous Deployment (CD)
Continuous Deployment takes automation one step further by removing the manual approval gate entirely.
- Fully Automated Releases: Once a change passes all automated test suites and validation checks in staging, it is automatically deployed directly into the live production environment.
- Faster Feedback Loop: End users receive bug fixes and new features within minutes of code being merged, drastically reducing release cycle overhead.
GitHub Actions#
GitHub Actions is a CI/CD platform built directly into GitHub. It allows automate build, test, and deployment pipelines through event-driven workflows defined in YAML files.
graph TD
Trigger["Git Push / Event"] --> Runner["Ephemeral Runner VM \n (Ubuntu / Windows / macOS)"]
subgraph CI Phase
Runner --> Checkout["git checkout"]
Checkout --> BuildTest["Build Code & Run Unit Tests"]
end
subgraph CD Phase
BuildTest --> Deploy["Deploy to Remote Server \n (SSH / SCP / Rsync)"]
end
Runner -- "VM destroyed on completion \n (Unsaved files lost!)" --> Destroy[("Clean State")]
- Workflow Triggers: define actions in .github/workflows/ci-cd.yml that run automatically on specific events, such as a push to main or a pull_request.
- CI Phase (Build & Test): The runner pulls code, compiles binaries, runs unit test suites, and checks code style.
- CD Phase (Deployment): If all tests pass, the workflow uses protocols like SSH or SCP to securely transfer built files or restart services on remote production server.
Understanding Ephemeral Runners (Transient VMs)#
A crucial concept in GitHub Actions is that workflow jobs run inside isolated, temporary virtual machines (or Docker containers) hosted on Linux, Windows, or macOS.
- Clean Slate Guarantee: Every run starts on a completely fresh machine with no leftover state from previous builds.
- Ephemeral Data: Because the runner environment is destroyed as soon as the job finishes, any generated files or build artifacts are permanently wiped.
- Persistence Requirements: If build process generates files that need to be saved (such as compiled web pages or release binaries), it must explicitly:
- Commit and push them back into a repository,
- Use workflow like actions/upload-artifact to upload them as workflow artifacts (temporary storage valid for 1 to 90 days).
- Transfer them directly to destination server via SCP/SSH.