Git has become synonymous with version control – it's the tool that tracks changes in your files
(especially source code) so you and others can collaborate without losing work. In this guide, we’ll level
up from the Git basics and explore intermediate concepts in a friendly, conversational way. We’ll briefly
revisit what Git is and how it came to be, then dive into key features like branching, merging vs.
rebasing, stashing changes, using logs and diffs to inspect history, and collaborating on platforms like
GitHub and GitLab. By the end, you’ll see why Git remains essential in modern development workflows
across software engineering, data science, and DevOps.
What is Git? (And a Whirlwind History Lesson)
Git is a distributed version control system (DVCS) – in plain terms, it's a tool to track versions of files and code. Unlike older centralized systems, Git gives every developer a full copy of the repository history, enabling work offline and in parallel. It was created in 2005 by Linus Torvalds (of Linux fame) after a bit of drama: the Linux kernel project had been using a proprietary DVCS called BitKeeper, but when the free use of BitKeeper was revoked, Linus and the community decided to build their own open-source tool. The goals were speed, simple design, strong support for parallel development (lots of branching), a distributed model, and the ability to handle large projects like the Linux kernel. Git achieved those goals – it’s fast, efficient, and famously good at branching and merging.
Over the years, Git has become the de facto standard for version control. In fact, as of 2022, nearly 95% of developers use Git as their primary version control system, making it the most widely used VCS in the industry. That ubiquity is thanks not only to Git’s capabilities, but also to platforms like GitHub and GitLab which built powerful collaboration features on top of Git. Before we talk about those, let's sharpen our Git skills with some core concepts beyond the “init, add, commit” basics.
Branching and Merging in Git

One of Git’s killer features is branching. In simple terms, a branch is like a parallel universe of your code where you can work on a specific feature or experiment without affecting the main project. Technically, a Git branch is just a pointer to a commit – a lightweight reference that moves as you make new commits. Creating a new branch in Git is nearly instantaneous and doesn’t copy all your files, which is a stark contrast to older version control systems where branching was slow or costly in disk space.
Why branch? Imagine you want to develop a new feature or fix a bug. Rather than coding directly on the main branch (also known as master in older repositories), you can spawn a branch (say, feature/login-page or bugfix/null-pointer) and do your work there. This keeps the main branch free from unstable code while you work. Teammates can continue updating main (for example, deploying hotfixes) without disturbing your in-progress feature. You can even have multiple branches for different efforts running in parallel. For instance, you might have one branch for a big upcoming feature and another for a quick tweak – Git handles this non-linear development with ease (it was designed for "thousands of parallel branches" after all).
Real-world example: Let’s say your team is building a website. Alice is tasked with overhauling the homepage design, while Bob is fixing a critical login bug. Alice creates a branch feature/new-homepage off main and starts committing her changes there. Bob creates a branch bugfix/login-issue and fixes the login problem. Both work simultaneously on separate branches. The main branch remains stable for other deployments. Once Bob is done, he can merge his bugfix branch back into main (more on merging in a second) to release the fix. Alice can merge her feature branch when the homepage is ready. By using branches, they avoided stepping on each other’s toes and kept the main line of code clean.
Merging branches
At some point, you’ll want to merge your changes from a branch back into another (often into main so the work goes live or into whatever branch is appropriate in your workflow). Merging in Git takes the commits from one branch and integrates them into another. Usually, this results in a special merge commit that ties the two branches’ histories together. For example, when you merge feature/new-homepage into main, Git will create a merge commit on main with two parent commits: one pointing to the tip of main before merge and one to the tip of feature/new-homepage. This merge commit records that the histories have combined.
One big advantage of Git’s merge is that it’s non-destructive – it doesn’t rewrite the existing commits on either branch; it just adds a new one to bring them together. This preserves the full history of what happened. The trade-off is that if your team merges main into feature branches frequently (to keep them updated), you can end up with a cluttered history full of extra merge commits. It can sometimes get a bit confusing to read a history with lots of forks and merges. But merges are straightforward and safe: everyone’s changes stay intact, and you can always trace back to see where a branch was merged.
Real-world merging: Suppose Alice finishes her homepage branch. She updates her branch with any new commits from main (to ensure there are no conflicts) and then merges feature/new-homepage into main. On GitHub or GitLab, this is often done via a Pull Request or Merge Request (more on those later) – you’d open a request to merge, get it reviewed, and click Merge. After merging, the main branch now includes Alice’s changes (the new homepage) along with Bob’s login fix. The history shows a merge commit that combines Alice’s branch. Meanwhile, the feature/new-homepage branch pointer can be deleted (the commits remain part of history).
Branching and merging are everyday Git tasks. In fact, many teams adopt a feature branch workflow where virtually all work is done on branches and merged in when ready (this keeps the main branch production-ready at all times). We’ll talk more about workflows in a later section, but first, there’s another approach to integrating changes that you’ll hear about: rebasing.
Merge vs. Rebase: What’s the Difference?
When integrating changes from one branch into another, developers have two main strategies: merge (which we just covered) or rebase. Both achieve the same end result (combining work), but they do it in different ways and have different trade-offs.
-
Merge: Combines branches by creating a merge commit. The branch histories remain branching – you’ll see a fork in the commit graph that later joins back together. Merging is simple and preserves complete history (including all the branching and merging structure). It’s non-destructive: the original commits from both branches stay as they were. The downside is a potentially messy history with extra merge commits, especially if you merge frequently or have many contributors.
-
Rebase: Integrates changes by moving the base of your branch to a new starting point (usually the tip of the target branch), and replaying your commits on top of it. Instead of a merge commit, rebasing literally re-writes your commit history: it creates new commits for each commit in your branch, as if you had started the branch from the latest state of
mainall along. The big benefit is a clean, linear history with no merge commits. If you look at a rebased history, it will seem as though all the work from the feature branch happened after the latest work onmain– no forks, just one straight line of commits. This can be easier to follow with tools likegit logbecause you don't see multiple divergent lines.
So why not always rebase? The catch is that rebasing changes history, which can be dangerous if not used carefully. If you rebase commits that have already been shared with others (e.g. pushed to GitHub), you will confuse collaborators and potentially cause conflicts, because their view of history will differ. There’s a famous Golden Rule of Rebasing: never rebase public branches. In other words, only rebase commits that you haven’t pushed yet (your own local feature branch), or you’ll end up forcing everyone to reconcile the divergent histories. If you break this rule, you might have to resort to force-pushing, which is risky and generally frowned upon in shared repositories.
In summary, merging is a safe, simple default that keeps all history but can clutter things with merges, whereas rebasing gives a tidy history but must be done carefully (only on private work) to avoid chaos. Many teams use a mix: they might rebase local commits for clarity (squashing trivial commits, etc.) and then do a final merge commit when integrating into the main branch (to clearly mark the merge). Others configure their GitHub/GitLab to fast-forward merges if possible (which effectively does a rebase-like integration without a merge commit when the branch is perfectly ahead). The choice often comes down to team preference:
-
If you value seeing exactly when merges happened and preserving contextual history (and you don't mind some clutter), merge is fine.
-
If you prefer a linear project history (and you script or coordinate to avoid public rebases), rebase can be a cleaner approach.
For example, if main has progressed while Alice worked on her feature, she has two options before merging: merge main into her feature branch (creating a merge commit on her branch) or rebase her feature branch onto main (moving her commits on top of the latest main). Merging will keep both sets of commits intact but add a merge commit in her branch history. Rebasing will incorporate the new main changes by rewriting her feature commits as if she started from the updated main, yielding a straight line of commits. In either case, the code result is the same; it’s the history that differs. After that, merging the feature branch into main would be fast-forward (if rebased) or a simple merge commit.
One more thing: if you’ve ever run git pull and seen a merge commit named something like “Merge branch 'main' into feature…”, it’s because by default git pull does a fetch + merge. If you want to avoid that, you can do git pull --rebase to rebase your local commits onto the newly fetched remote changes instead. This is a common setting for those who prefer linear history.
Key takeaway: Use merge when you want simplicity and safety, and use rebase when you want a cleaner history and are working on a private branch. But remember the golden rule – don’t rebase commits that others have (never rewrite shared history). If in doubt, merging is always a solid choice since it never loses information. Rebasing, when done right, makes the commit log look like you all took turns nicely one after another – which can be nice for reviewing history. Many intermediate Git users learn to love interactive rebasing (git rebase -i) to squash or edit commits before sharing them, making their branch history clean and logical. Just be cautious to only do this on your own branch before you push it.
Shelving Work in Progress with Git Stash

Sometimes you’re in the middle of coding something, and suddenly you need to drop it and switch context – maybe an urgent bug came up, or you want to pull the latest changes from the remote without committing half-done work. Git stash is a handy tool for these situations. The git stash command allows you to temporarily save (“stash”) your uncommitted changes on a stack, clean your working directory, and come back to those changes later.
Think of it as putting your work-in-progress in a briefcase: you pack up the changes, set them aside, and your working directory goes back to the last committed state, as if you never started the new work. Later, you can open the briefcase and get all your changes back exactly where they were.
A practical scenario: you’re working on Branch A adding a new feature, with a couple of files modified and not yet committed. Suddenly, a teammate or your boss asks you to urgently fix a bug on Branch B. If you try to git checkout B, Git will complain that you have uncommitted changes – you either have to commit them or stash them. Committing half-baked code just to switch branches isn’t ideal. Instead, you do git stash, which saves all your modified files (and staged changes) to a stash and reverts your working copy to a clean state. Now you can switch to Branch B, the checkout will succeed (because your working tree is clean), and go fix the bug.
Let's outline that step-by-step as an example workflow using stash:
-
You’re partway through some edits on branch A (but not ready to commit yet).
-
Run
git stashto save those changes on the stash stack and revert your working directory to the last commit on branch A. -
Switch to branch B (
git checkout B). -
Fix the urgent bug on branch B, commit the fix, and (if needed) push it.
-
Switch back to branch A (
git checkout A). -
Run
git stash popto reapply the stashed changes onto branch A, picking up right where you left off.
This is exactly the kind of scenario git stash was designed for. The stash lets you context-switch without cluttering your history with temporary commits or risking losing changes by manual methods. When you run git stash pop, it tries to apply the saved changes back to your working copy and then removes that entry from the stash list. (If you want to apply without removing from stash, you can use git stash apply instead, in case you need to use it again or apply to multiple branches.)
A few things to note about stashes:
-
Stash is local. Stashed changes are kept in your local repository’s Git data (in the
.gitfolder) and are not pushed to the remote. If you stash something on one machine, it won’t magically be available on GitHub or on another clone – you’d need to commit and push if you want to share that work. Think of stash like your personal clipboard or drawer. -
Multiple stashes: You can stash multiple sets of changes. Each
git stashpush creates a new entry in a stack (accessible viagit stash list). You might see stash entries labeled likestash@{0},stash@{1}, etc. You can apply or drop specific ones if needed. In practice, many devs only keep one or two stashes at a time, but it's good to know you can have many. -
What gets stashed: By default,
git stashsaves tracked modified and staged files. It will not include new untracked files (unless you usegit stash -u) nor ignored files (unless-afor all). This prevents it from stashing things like build outputs or new files you maybe didn’t intend to add. If you do need to stash untracked files, remember the-uor--include-untrackedoption.
Using git stash effectively can really smooth your workflow. It’s much quicker and cleaner than the “alternative” of committing unfinished work (with messages like “WIP” – Work In Progress – which you’d later amend or squash). Stash saves the uncommitted changes safely, and you can get back to a clean working directory to pull updates or work on something else. Once ready, reapplying the stash gets you back in action on the original task. Many developers also use stash to experiment: stash changes, try something out, and if it doesn’t pan out, just drop the stash instead of applying it.
In short, git stash is your friend whenever you need to park your current work without committing. It keeps your flow going and avoids dirty working states. If you haven’t used it much yet, give it a try next time you’re forced to multitask – it can be a real lifesaver for context switching.
Inspecting History with Git Log and Diff
As you work with Git, the history of commits becomes an important resource. Two command-line tools you’ll frequently use are git log (for viewing commit history) and git diff (for seeing changes). Mastering these will help you understand what happened in your repository and when.
Viewing commits with git log
The git log command shows the list of commits in the current branch (or any branch you specify), starting from the most recent and going backwards. By default, git log will show each commit’s hash, author, date, and message. This is the journal of your project. Reading the log answers questions like: “Who made the last change to this file?”, “What changes were included in last week’s release commit?”, or “When was this feature merged?”.
Remember, the whole point of using version control is to record changes to your code over time so you can recall or revert them if needed. The log is essentially that record. It lets you go back in history to see who changed what and when. For example, if a bug popped up in version 1.2 of your app, you might check the log to find all commits that touched the module in question in the last few weeks.
Git’s log is very configurable. You can pass flags to format it or filter it. A couple of handy ones:
-
git log --oneline --graph: shows a compact view of commits (one line each) and a text-based graph of branches/merges. This is great for visualizing branching history at a glance. -
git log -p: shows the diff (patch) for each commit in the log (so you see the actual code changes each commit introduced). This can be verbose, but useful for reviewing changes without checking out old revisions. -
git log --stat: shows a summary of files changed and the number of insertions/deletions for each commit. -
git log <branchA>..<branchB>(double dot syntax): shows commits that are in branchB and not in branchA, which is a neat way to see what commits would be brought in if you merged branchB into A (or vice versa). -
git shortlog: groups commits by author, often used for release notes or to see contribution distribution.
For everyday use, git log without options or with --oneline is common. If you prefer a GUI or IDE, tools like GitHub Desktop, GitKraken, or VS Code’s Git Graph can display commit history more visually. But under the hood, it’s the same info.
Viewing changes with git diff
While git log is about who/when/what in commits, git diff is about the actual content difference between two states. The git diff command compares two sets of data (which could be two commits, a commit and your working tree, etc.) and outputs the line-by-line changes (additions and deletions). If log tells you “Alice changed file X in commit abc123”, then git diff abc123^ abc123 would show exactly what Alice changed in that commit.
In practice, you often use git diff in a few ways:
-
Check your unstaged changes: Just run
git diffwith no arguments when you have local modifications. It will show the differences between your working files and the staging area (or last commit). In other words, “what have I changed that I haven’t committed yet?” -
Check staged vs committed: After staging files (with
git add), you can dogit diff --staged(or--cached) to see what you’ve added to the next commit compared to the HEAD commit. -
Compare two commits or branches: e.g.
git diff main..feature/login-page(two-dot range) shows what changes the feature branch has relative to main. Orgit diff v1.0 v1.1to see what changed between two release tags. -
Compare a commit to its parent:
git diff <commit>^!will show the diff for a single commit (the changes that commit introduced). This is essentially whatgit show <commit>does as well.
The output of git diff might look a bit arcane at first, but it’s standardized: added lines are prefixed with +, removed lines with -, and a few context lines around the changes are shown. Being comfortable reading diffs is hugely beneficial – not only for using Git, but also for code reviews. In fact, when you open a Pull Request on GitHub, what do you see? Essentially a diff of your changes. So git diff on the command line gives you a similar view.
In summary, git diff lets you see the changes between any two versions (commits, branches, or your working copy). It’s often used in conjunction with git status and git log as you prepare commits or review history. A typical habit: you make some changes, run git diff to double-check what you've modified, then stage and commit. Later, you might run git log -p to review what you committed if you need to recall details.
One more neat usage: if you ever want to generate a patch file of differences, you can redirect diff output to a file (or use git format-patch). But that’s an advanced trick; the everyday win is using git diff to sanity-check your work and understand others’ changes.
Collaboration with GitHub and GitLab: Pull Requests, Reviews, and CI
While Git the tool is independent of any platform, much of Git’s popularity is tied to collaboration platforms like GitHub and GitLab. These platforms host your repositories and add a whole suite of features to facilitate team collaboration: issue trackers, wikis, code review tools, continuous integration, and more. Let’s explore some key collaboration concepts, focusing on GitHub and GitLab (which are quite similar in many ways, with GitLab often used in self-hosted or enterprise settings and GitHub as the largest public code host).