Master the art of version control with Git. From basic workflows to advanced branching strategies and internals used in professional DevOps environments.
July 2026
In modern software engineering, the ability to track changes, collaborate across disparate teams, and maintain a historical record of a project is not merely a convenience—it is a foundational requirement. Version Control Systems (VCS) provide a mechanism for managing changes to source code over time. Without such systems, developers would be forced to manually manage file copies (e.g., project_v1, project_final_v2), which is inherently error-prone and lacks the granularity required for complex systems.
Historically, VCS was divided into two primary architectures: Centralized and Distributed.
Systems like Subversion (SVN) and Perforce rely on a single central server that contains all the versioned files. Clients check out files from that central place. This model offers a single point of authority and fine-grained access control. However, it introduces a single point of failure: if the server goes down, collaboration ceases, and if the disk is corrupted without proper backups, the entire history is lost.
Git belongs to the distributed category. In a DVCS, every client maintains a full clone of the repository, including the entire history. This redundancy ensures that if any server dies, any client repository can be used to restore the system. Furthermore, most operations are local, providing significant performance advantages.
Git was created in 2005 by Linus Torvalds during the development of the Linux kernel, following the loss of access to BitKeeper. Torvalds designed Git with several non-negotiable goals:
Unlike older VCS that store deltas (file changes), Git captures snapshots of the entire filesystem. When you commit, Git records what every file looks like at that moment. If a file has not changed, Git simply stores a link to the previous version, significantly optimizing storage and retrieval.
Git utilizes SHA-1 hashes to identify content. Every file or directory is referred to by its checksum, making it impossible to alter the records without Git detecting the change. A commit is identified by a 40-character hexadecimal string, ensuring a permanent and verifiable state.
Because Git stores the entire history locally, most operations look like they are instantaneous. For example, to browse the history of a project, Git doesn’t need to go to the server to get the log—it simply reads it directly from your local database. This architecture enables a workflow where developers can commit frequently and experiment with branches without overhead.
Git is designed to be portable across all POSIX-compliant systems and Windows. In academic and professional settings, you will likely encounter a heterogeneous environment where developers use different operating systems.
The preferred method for installing Git is through the system’s native package manager to ensure compatibility and easy updates.
On Debian-based systems (Ubuntu, Mint):
sudo apt update && sudo apt install git
On Red Hat-based systems (Fedora, RHEL):
sudo dnf install git
On Arch Linux:
sudo pacman -S git
On FreeBSD:
pkg install git
While macOS comes with a version of Git installed via Xcode Command Line Tools, many developers prefer the more up-to-date version from Homebrew:
brew install git
For Windows, Git for Windows (also known as Git Bash) is the standard. It provides a BASH emulation environment which is critical for maintaining script compatibility across teams. You can install it via Winget:
winget install --id Git.Git -e --source winget
Git records the identity of the author for every commit. This is not for authentication, but for accountability and metadata. These settings are stored in the ~/.gitconfig file (or %USERPROFILE%\.gitconfig on Windows).
git config --global user.name "Leonardo da Vinci"
git config --global user.email "leo@renaissance.org"
The --global flag ensures that these settings are applied to every repository on your machine. For project-specific identities (e.g., using a work email for a specific repo), you can omit the flag while inside that repository.
One of the most common issues in cross-platform development is how different operating systems handle the end of a line in a text file.
\r\n).\n).If not managed, Git will see the change in line endings as a change to the entire file, leading to “merge hell.”
You should configure Git to convert LF to CRLF when checking out code, and convert CRLF back to LF when committing:
git config --global core.autocrlf true
You should ensure that Git only converts CRLF to LF on commit, but doesn’t do anything on checkout:
git config --global core.autocrlf input
By default, Git may fall back to vi or vim. If you are not comfortable with modal editors, you should change it to a simpler one like nano or a code editor like VS Code:
# To use Nano
git config --global core.editor "nano"
# To use VS Code
git config --global core.editor "code --wait"
# Command to list all active configurations
git config While user.name and user.email identify you in the history, they do not verify your identity. In a DevOps pipeline, you will typically use SSH Keys or Personal Access Tokens (PAT) for authentication with remotes like GitHub or GitLab.
If you prefer SSH for secure communication without typing passwords:
ssh-keygen -t ed25519 -C "your_email@example.com"
This generates a public/private key pair. You would then provide the public key (~/.ssh/id_ed25519.pub) to your Git hosting provider.
To understand Git, one must understand its workflow, which is centered around three main sections: the Working Directory, the Staging Area (also known as the Index), and the Git Directory (Repository).
Under the hood, Git is essentially a content-addressable filesystem. It is a simple key-value store. When you insert any piece of content into the Git repository, it gives you back a unique key (the SHA-1 hash) that you can use to retrieve that content.
There are three primary types of objects in the Git database:
A blob stores the file data, but not the file name or any metadata. If two files have the exact same content, they will share the same blob in the Git database, regardless of their names.
A tree solves the problem of storing filenames and also allows you to group files together. One tree object contains a list of entries, each of which is the SHA-1 hash of a blob or another tree, along with its associated mode, type, and filename. This is analogous to a directory in a filesystem.
A commit object points to a single tree, marking what the project looked like at that point in time. It also contains the author, the committer, a timestamp, a message, and pointers to the parent commit(s).
.git Directory StructureIf you look inside the hidden .git folder in any repository, you will see the mechanics of how Git works:
config: Project-specific configuration settings.description: Used by the GitWeb program.HEAD: Points to the branch you currently have checked out.hooks/: Scripts that run on certain events (e.g., pre-commit).info/: A global exclude file for ignored patterns.objects/: The heart of Git—all the blobs, trees, and commits.refs/: Pointers to master/main, tags, and remotes.One of the most powerful aspects of Git’s architecture is that objects are immutable. Once a blob or a commit is written to the database, it cannot be changed. If you modify a file and commit it, Git creates a new blob and a new commit. The old ones remain in the database (until pruned by garbage collection), which is why it is so difficult to truly lose data in Git once it has been committed.
The lifecycle of a Git project begins with the init command. This creates the .git directory and sets up the necessary infrastructure for tracking.
mkdir my-university-project
cd my-university-project
git init
By default, Git will create a branch (usually named master or main). From this point forward, Git will watch for changes in this directory.
The most frequently used command is git status. It provides a summary of which files are in which state (Tracked, Untracked, Modified, Staged).
git status
The workflow in Git is a two-step process: Staging and Committing.
git addStaging allows you to group related changes together. You might have changed ten files, but only five of them are related to a specific bug fix. You can stage just those five:
git add file1.c file2.c
git commitA commit is a permanent snapshot. It is critical to write descriptive commit messages that explain why a change was made, not just what was changed.
git commit -m "Refactor memory allocation in parser to prevent overflow"
To see exactly what has changed in your files since the last commit (but before you stage them), use git diff:
git diff
Once staged, you can use git diff --staged to see what is ready to be committed.
The git log command displays the commit history in reverse chronological order.
git log --oneline --graph --decorate
.gitignoreIn any project, there are files that you never want to track:
*.o, *.exe, bin/).vscode/, .idea/).env, secrets.json)node_modules/, venv/)A .gitignore file is a text file where each line contains a pattern for files/directories to ignore.
# Ignore all object files
*.o
# Ignore the build directory
/build/
# Ignore sensitive files
.env
A “Best Practice” in DevOps is the concept of Atomic Commits. Each commit should represent a single logical change. If you are halfway through a feature and you find a typo in a completely different part of the codebase, don’t include the typo fix in your feature commit. Commit them separately. This makes the history easier to read, revert, and debug (e.g., using git bisect).
# Find which branch you are currently on
git In many older VCS, branching involved creating a full copy of the source code—which was slow and expensive. In Git, a branch is simply a lightweight, movable pointer to one of the commits in the repository. The default branch name is usually main. When you create a new branch, Git creates a new pointer; it does not duplicate any file content.
Each pointer is a tiny file (41 bytes) containing the 40-character SHA-1 checksum of the commit it points to.
To create a new branch named testing:
git branch testing
However, creating a branch does not switch you to it. To start working on that branch, you must “check it out” or “switch” to it:
git switch testing
(Note: In older tutorials, you will see git checkout -b testing. Modern Git recommends git switch as it is more intuitive.)
Merging is the process of bringing changes from one branch into another. There are two primary types of merges you will encounter.
If the branch you are merging into is a direct ancestor of the branch you are merging (i.e., there have been no other commits on the base branch), Git simply moves the pointer forward. No new commit is created.
git switch main
git merge feature-x
If the history has diverged (i.e., both main and feature-x have new, different commits), Git performs a three-way merge. It looks at three snapshots:
Git creates a new “Merge Commit” that has two parents.
A conflict occurs when the same line of the same file was modified in both branches being merged. Git will stop and ask you to resolve the conflict manually.
<<<<<<< HEAD
printf("Hello from Main\n");
=======
printf("Hello from Feature\n");
>>>>>>> feature-x
You must edit the file, choose the correct version (or combine them), remove the markers, and then git add and git commit to complete the merge.
# Create and immediately switch to 'dev' branch git switch dev
One of Git’s defining characteristics is that it is a Distributed Version Control System (DVCS). Unlike Centralized VCS (like Subversion or CVS) where clients checkout the latest version of files, in Git, every client mirrors the repository fully.
This means that if the server dies, any of the client repositories can be sent up to the server to restore it. Every clone is really a full backup of all the data.
To collaborate with others, you use “remotes”. A remote is simply a reference to a version of your project that is hosted on the internet (e.g., GitHub, GitLab) or network.
To see which remotes you have configured:
git remote -v
This will list the shortnames (like origin) and the URLs that Git has stored for reading and writing.
If you initialized a repository locally, you need to add a remote explicitly to push your code.
git remote add origin https://github.com/user/repo.git
Here, origin is just the standard default name for your primary remote, but you could name it anything (e.g., upstream, backup, production).
If you are starting a project that already exists remotely, you clone it.
git clone https://github.com/liberuniversity/course-content.git
This command does several things:
.git directory.main branch to track the remote origin/main branch.Collaboration involves sending your commits to a remote and downloading commits from others.
There is a crucial distinction between fetch and pull that often confuses beginners.
git fetch downloads the data from the remote project that you don’t have yet. It updates your “remote-tracking branches” (like origin/main), but it does not merge changes into your working directory. It is safe and non-destructive.
git fetch origin
git pull is essentially a git fetch followed immediately by a git merge. It downloads the changes and tries to merge them into your current branch.
git pull origin main
Recommendation: If you want to see what others have done before integrating it, use fetch then log (or diff), and finally merge. If you just want to get up to date and resolve conflicts later, use pull.
When you have commits that you want to share, you must push them upstream.
git push origin main
This command takes your local main branch and pushes it to the origin remote. This only works if you have write access and if no one else has pushed in the meantime. If someone else has pushed, you must pull their changes and merge them before you can push.
The first time you push a new branch, you often need to set the “upstream” (or tracking) relationship. This links your local branch to the remote branch so you can use git pull and git push without arguments later.
git push -u origin feature-login
The -u (or --set-upstream) flag establishes this link.
Remote-tracking branches are references to the state of remote branches. They are local and you cannot move them; Git moves them for you whenever you do network communication. They act as bookmarks.
They take the form <remote>/<branch>. For example, origin/main.
If you are on your local main branch and you do git fetch origin, Git updates origin/main to reflect the latest commit on the server. Your local main pointer stays where it was.
Once a feature is merged and the branch is deleted on the server, you might still have the reference locally. To clean up your local references:
git fetch --prune
To explicitly delete a branch on the remote server:
git push origin --delete feature-login
# Add a remote named 'upstream' with url 'https://git.example.com/repo.git' git remote upstream https://git.example.com/repo.git
For many developers, the words CONFLICT (content): Merge conflict in ... induce a mild panic. However, conflicts are a normal part of collaboration. They simply mean that Git needs human intervention to decide how to integrate two divergent sets of changes.
A conflict occurs when:
Git is smart enough to merge changes in different parts of the same file automatically. But when lines overlap, it stops and asks for help.
When a conflict happens, Git pauses the merge process and marks the problematic files as “Unmerged”. If you open such a file, you will see standard conflict markers:
<<<<<<< HEAD
var taxRate = 0.20;
=======
var taxRate = 0.22;
>>>>>>> feature/new-tax-laws
<<<<<<< HEAD: The content between this line and ======= is what exists in your current branch (the one you are merging into).=======: The separator.>>>>>>> feature/new-tax-laws: The content between the separator and this line is what is coming from the incoming branch.To resolve the conflict, you must edit the file to look exactly how you want the final result to be. This means removing the markers (<<<<<<<, =======, >>>>>>>) and choosing one version, or combining them.
Example Resolution: If you decide the new tax rate should be used, you edit the file to:
var taxRate = 0.22;
After editing, you must stage the file to tell Git “I have resolved this.”
git add file.js
git commit
Note that git commit without arguments will open your editor with a default merge message.
Sometimes you realize you are in over your head or you started the merge on the wrong branch. You can always bail out.
git merge --abort
This commands resets your working directory to the state before the merge began, wiping away all conflict markers and partial changes.
While manual editing is fine for simple conflicts, complex ones benefit from a dedicated “Merge Tool” (like KDiff3, Meld, P4Merge, or VS Code’s built-in 3-way editor).
To configure a tool (e.g., VS Code):
git config --global merge.tool vscode
git config --global mergetool.vscode.cmd 'code --wait $MERGED'
Then, when in a conflict state:
git mergetool
Sometimes you know that you strictly want to keep “your” version or fully accept “their” version for a specific file, without looking at the details.
To checkout your version (ignoring incoming changes):
git checkout --ours path/to/file
To checkout their version (overwriting your changes):
git checkout --theirs path/to/file
Be careful! This discards the other side’s changes completely for that file.
Git cannot show diffs for binary files (images, compiled binaries). If you have a conflict in image.png, you have to choose one whole file or the other.
# Keep our image
git checkout --ours image.png
git add image.png
# During a merge conflict, you decide to fully accept the incoming version for 'config.json' git --theirs config.json
As a project grows, the history becomes a massive database of changes. git log is the query language for this database. Using it effectively can save you hours of debugging.
The default git log is verbose. To get a high-level overview, you can customize the output format.
git log --oneline
This shows just the abbreviated SHA and the commit message.
To see where branches and tags are pointing:
git log --oneline --decorate
To visualize the branching history in the terminal:
git log --oneline --graph --all
This draws an ASCII art tree of your commit history, showing merges and divergences.
You often want to find specific commits rather than listing everything.
git log --since="2 weeks ago"
git log --until="2023-01-01"
git log --author="Linus"
To see only commits that modified a specific file:
git log -- path/to/file.py
To search for commits that added or removed a specific string (the “Pickaxe”):
git log -S "functionName"
If git log lists the commits, git show inspects the details of a single object.
git show a1b2c3d
This displays the log message and the diff of what changed in that commit.
When you encounter a bug, you often want to know: “Who wrote this line and when?” git blame annotates each line of a file with the commit information.
git blame main.c
Output:
^4a2b3c (Alice 2023-01-01 10:00:00 +0000 1) int main() {
8d7e6f (Bob 2023-01-02 11:00:00 +0000 2) return 0;
^4a2b3c (Alice 2023-01-01 10:00:00 +0000 3) }
In many IDEs, this functionality is integrated as “Git Lens” or “Annotate”.
Git keeps a log of where your HEAD pointer has been, even if you deleted branches or reset commits. This is called the “Reference Log” or reflog.
git reflog
It shows a local history of your actions (switching branches, resetting, committing). It is invaluable for recovering “lost” commits.
We’ve used git diff to see working directory changes. But it is much more powerful.
To see what is in feature that is not in main:
git diff main..feature
To see the difference between two specific points in time:
git diff HEAD~2 HEAD
# Complete the command to show a decorated, one-line graph of all branches
git log --oneline --decorate --graph --One of the main reasons we use version control is the ability to undo mistakes. Git provides several tools for this, ranging from safe reversals to destructive history rewriting.
If you have modified a file but haven’t staged it yet, and you want to throw away your changes (revert the file to how it looks in the last commit):
git restore file.txt
(In older Git versions, this was done with git checkout -- file.txt)
If you have staged a file and want to unstage it (but keep the changes in your file):
git restore --staged file.txt
(Previously git reset HEAD file.txt)
git reset Commandgit reset is a powerful tool that moves the HEAD pointer (and the branch pointer) to a specific commit. It has three main modes that determine what happens to your Index (staging area) and Working Directory.
--soft)Moves HEAD to the target commit. Does not touch the Index or Working Directory. Result: Your changes since that commit are now “staged” and ready to be committed again. Useful for squashing the last few commits.
--mixed) - DefaultMoves HEAD to the target commit. Resets the Index to match the target commit. Does not touch the Working Directory. Result: Your changes are preserved but are “unstaged”.
--hard)Moves HEAD, resets Index, and overwrites the Working Directory. Result: All changes since the target commit are lost permanently.
# Go back 3 commits, throw away everything
git reset --hard HEAD~3
If you have already pushed your changes to a shared repository, using reset is dangerous because it rewrites history that others might have based their work on.
Instead, use git revert.
git revert <commit-hash>
This creates a new commit that applies the exact opposite of the changes introduced by the target commit. It is a “forward-moving” undo.
Example:
git revert A creates Commit B which removes line “Hello”.If you just committed but forgot to add a file, or made a typo in the message, you don’t need to reset.
git add forgotten_file.js
git commit --amend
This replaces the last commit with a new one containing the combined changes. Do not use this if you have already pushed the commit.
If your directory is cluttered with untracked files (build artifacts, temporary files) that you want to delete:
git clean -fd
-f means force.-d means remove directories too.Warning: This deletes files permanently.
# You accidentally staged 'secret.key'. Unstage it without deleting the file. git --staged secret.key
You are deep in the zone, coding a complex feature. Suddenly, a critical bug is reported in production. You need to switch to the main branch to fix it, but your current working directory is a mess of half-finished changes.
You can’t commit, because the code doesn’t compile. You can’t just switch branches, because Git will forbid it if your changes conflict with the destination branch.
Enter git stash.
Stashing takes the dirty state of your working directory (modified tracked files and staged changes) and saves it on a stack of unfinished changes that you can reapply at any time.
git stash
Your working directory is now clean (matching the HEAD commit). You can safely switch branches, fix the bug, and commit.
By default, stashes are named “WIP on branch…“. To make them easier to find later:
git stash push -m "Experimenting with new login logic"
Once you are done with the bug fix and back on your feature branch, you want your changes back.
git stash pop removes the latest stash from the stack and applies it to your working directory.
git stash pop
If you want to apply the stash but keep it in the stack (e.g., to apply it to multiple branches):
git stash apply
You can have multiple stashes.
git stash list
Output:
stash@{0}: On feature: Experimenting with new login logic
stash@{1}: WIP on main: 4d3e2f1 Fix typo
To apply a specific stash:
git stash apply stash@{1}
To delete a stash:
git stash drop stash@{0}
To clear all stashes:
git stash clear
By default, git stash only saves tracked files. If you have created new files (untracked), they will remain in your working directory. To stash them as well:
git stash -u
(or --include-untracked)
Sometimes you just want to destroy untracked files. Maybe your build script generated a thousand .o files and you want to start fresh.
git clean removes untracked files from the working directory.
Dry Run: Always do this first to see what would be deleted.
git clean -n
Force Clean: Actually delete the files.
git clean -f
Directories too:
git clean -fd
Ignored files: To remove ignored files as well (e.g., node_modules):
git clean -fdX
# You want to see the list of all saved stashes to find an old experiment.
git stash Both git merge and git rebase are designed to integrate changes from one branch into another. However, they do it in very different ways.
Merging preserves history exactly as it happened. It brings two lines of history together in a “merge commit”.
Rebasing rewrites history. It takes the commits from your branch and “replays” them on top of another branch.
Never rebase public history.
If you rebase commits that you have already pushed and others have pulled, you are rewriting history that they depend on. This will cause chaos. Only rebase local commits that you haven’t shared yet.
The most powerful form of rebasing is “Interactive Rebase” (-i). This allows you to edit, delete, squash, and reorder commits as they are being replayed.
git rebase -i HEAD~3
This opens an editor with a list of the last 3 commits:
pick a1b2c3d Fix login bug
pick 4e5f6g7 Add unit tests
pick 7h8i9j0 Update documentation
You can change the word pick to other commands:
Squashing is a common pattern to clean up a messy history before merging. If you have 10 commits saying “WIP”, “Typo”, “Fixing bug”, you can squash them into one clean “Implement Feature X” commit.
Example:
pick a1b2c3d Implement Feature X
squash 4e5f6g7 WIP
squash 7h8i9j0 Fix typo
This merges the 3 commits into one, prompting you to write a new commit message for the combined result.
By default, git pull performs a merge. If you want to keep your local history linear on top of the remote changes, you can pull with rebase.
git pull --rebase
You can set this globally:
git config --global pull.rebase true
If you know you want to fix up a previous commit while you are making the change, you can use autosquash.
--fixup:
git commit --fixup <commit-hash-to-fix>
git rebase -i --autosquash HEAD~5
Git will automatically rearrange the TODO list to pair the fixup commit with its target.
# You want to interactively rebase the last 5 commits to cleanup history. git rebase HEAD~5
Commits are identified by SHA-1 hashes (e.g., a1b2c3d), which are great for computers but terrible for humans. When you reach a significant point in your development—like a release—you want to give it a permanent, meaningful name like v1.0.0. This is what Tags are for.
Git supports two types of tags: Lightweight and Annotated.
A lightweight tag is very much like a branch that doesn’t change. It’s just a pointer to a specific commit. It contains no extra information—no author, no date, no message. It is simply a bookmark.
git tag v1.0-beta
Annotated tags are stored as full objects in the Git database. They are checksummed; contain the tagger name, email, and date; have a tagging message; and can be signed and verified with GNU Privacy Guard (GPG).
You should generally use annotated tags for all public releases. This preserves the information of who created the release and when, independently of the commit it points to.
git tag -a v1.0.0 -m "Release version 1.0.0"
While Git allows any string as a tag, the industry standard is Semantic Versioning (SemVer). A version number looks like MAJOR.MINOR.PATCH.
Example: v2.14.3 indicates Major version 2, Minor version 14, Patch 3.
Pre-release versions can be denoted with a hyphen, e.g., v1.0.0-alpha.1.
Listing tags is straightforward, but in a large project, you might have hundreds.
git tag
You can search for tags with patterns using the -l or --list option:
git tag -l "v1.8*"
By default, Git sorts tags lexicographically. However, this can be confusing because v1.10 comes before v1.2. To sort by version number properly:
git tag --sort=version:refname
By default, git push does not transfer tags to remote servers. You must explicitly push tags.
To push a single tag:
git push origin v1.0.0
To push all tags that are not yet on the remote:
git push origin --tags
To delete a tag locally:
git tag -d v1.0.0
To delete it from the remote:
git push origin --delete v1.0.0
You can checkout a tag to put your repository in “detached HEAD” state at that version. This is useful for building old versions or debugging regressions.
git checkout v1.0.0
Note: You cannot commit changes directly to a tag. You must create a branch from it first.
For high-security projects, it is critical to prove that a release was actually created by the maintainer and not a malicious actor.
git tag -s v1.5.0 -m "Signed release"
This requires GPG to be set up. Users can verify the signature with:
git tag -v v1.5.0
If the signature is valid and the key is trusted, Git will report a “Good signature”.
# You have created several tags locally. Push ALL of them to origin.
git push origin Git has a way to fire off custom scripts when certain important actions occur. These are called Hooks. They are essentially the “events” of the Git lifecycle.
There are two groups of hooks:
Hooks are stored in the .git/hooks directory of your repository. By default, Git populates this folder with sample scripts (pre-commit.sample, etc.). To enable a hook, you simply create an executable file with the name of the hook (no extension).
pre-commitThis hook runs before you even type a commit message. It is used to inspect the snapshot that is about to be committed.
Example pre-commit script (Bash):
#!/bin/sh
echo "Running tests..."
npm test
if [ $? -ne 0 ]; then
echo "Tests failed! Commit aborted."
exit 1
fi
prepare-commit-msgRun before the commit message editor is fired up but after the default message is created.
commit-msgTakes one parameter (the path to the temporary file containing the commit message).
post-mergeRuns after a successful merge command.
npm install) if package.json changed.These scripts run on the server (remote) when it receives a push.
pre-receiveThe first script to run when handling a push from a client. It takes a list of references that are being pushed from stdin.
updateSimilar to pre-receive, but runs once for each branch the pusher is trying to update.
post-receiveRuns after the entire process is completed.
Hooks are stored in .git/hooks, which is not version controlled. This means if you clone a repo, you don’t get the hooks.
To share hooks with your team, you have two common strategies:
Symlinks: Store scripts in a scripts/hooks folder (which is versioned) and symlink them to .git/hooks.
git config core.hooksPath scripts/hooks
Husky (Node.js): A popular tool for JavaScript projects that automatically configures hooks.
// package.json
"husky": {
"hooks": {
"pre-commit": "npm test"
}
}
Sometimes you need to commit code that you know fails the linter (e.g., a WIP save). You can bypass client-side hooks with:
git commit --no-verify
(or -n)
# You need to commit immediately and skip the pre-commit hook. git commit -m "Emergency fix"
Modern software is built on libraries. Usually, you use a package manager (npm, pip, cargo) to handle this. But sometimes, you need to depend on another Git repository directly—perhaps a private library or a C++ project that needs to be compiled from source.
Git offers two main ways to embed one repository inside another: Submodules and Subtrees.
A submodule is a pointer to a specific commit in another repository. It stays in a subdirectory of your main project, but Git sees it as a separate entity.
git submodule add https://github.com/libs/mylib.git libs/mylib
This creates a .gitmodules file (which tracks the mapping between URL and path) and downloads the code into libs/mylib.
When you clone a project that has submodules, the directories will be empty by default. You must initialize and update them.
git clone https://github.com/my/project.git
cd project
git submodule init
git submodule update
Or, do it all at once:
git clone --recurse-submodules https://github.com/my/project.git
When you enter a submodule directory, you are in a “Detached HEAD” state pointing to the commit recorded by the parent project. If you make changes and commit them, they exist only in that submodule. To update the parent project to use your new changes:
git add libs/mylib (this updates the pointer).git commit.Subtree is an alternative that allows you to nest one repository inside another as a normal subdirectory. Unlike submodules, the files are actually present in the parent repository’s history.
git subtree add --prefix=libs/mylib https://github.com/libs/mylib.git main --squash
This merges the history of the library into your project under the libs/mylib folder.
To pull in new changes from the library:
git subtree pull --prefix=libs/mylib https://github.com/libs/mylib.git main --squash
Submodules:
--recurse-submodules.Subtrees:
# You want to clone a repo and automatically fetch all nested submodules. git clone https://host/repo.git
Git is a tool, not a workflow. It allows you to create branches, merge them, and rewrite history, but it doesn’t tell you how your team should use these features. A “Workflow” is a set of rules your team agrees upon.
Popularized by Vincent Driessen in 2010, Gitflow is a strict branching model designed for project releases.
Pros: robust, well-defined for packaged software. Cons: complex, slows down continuous delivery.
A simpler workflow used by GitHub and many modern web teams.
main branch is deployable.main.main after approval.Pros: simple, supports CD (Continuous Deployment). Cons: may be too simple for complex release cycles with versioned artifacts.
The gold standard for high-performing DevOps teams (like Google, Facebook).
Pros: no “merge hell”, instant feedback, enables true CI. Cons: requires high discipline and strong automated testing.
Regardless of the branching model, the mechanism for code review is the Pull Request (PR).
A PR is a request to merge your branch into a target branch. It provides a UI for:
# Create a new branch named 'feat/ui' and switch to it. git switch feat/ui