🌿

Git Intermediate

Collaborate confidently: branching strategies, pull requests, complex merges, rebasing workflows and forks.

32 lessons 96 quiz questions
Lessons & quizzes Certificate

📚 Lessons & quizzes

Each lesson ends with its own short quiz. Answer them as you go — score 90% across all lessons to earn your certificate.

1 Choosing a Branching Strategy

A branching strategy is a team agreement about how branches are named, created, integrated and deleted. There is no single “best” strategy — the right one depends on how often you ship, how big the team is, and whether you support multiple released versions at once.

  • GitHub Flow — one long-lived branch (main), short feature branches, deploy from main.
  • GitLab Flow — like GitHub Flow but adds environment or release branches.
  • GitFlow — long-lived develop and main, plus feature/*, release/* and hotfix/*.
  • Trunk-based development — everyone commits to one trunk many times a day behind feature flags.

The deeper the integration is delayed, the harder merges become. Strategies that keep branches short-lived reduce painful conflicts.

# List branches and see which one tracks which remote
git branch -vv

2 GitHub Flow in Depth

GitHub Flow is the simplest mainline workflow. There is exactly one long-lived branch, main, which is always deployable. Every change — feature or fix — happens on a short topic branch created from main.

  1. Branch off main with a descriptive name.
  2. Commit, push, and open a pull request early for discussion.
  3. Get review and green CI, then merge back into main.
  4. Deploy from main; delete the topic branch.

It works best when you can deploy frequently and roll forward fixes quickly. It has no separate develop or release branches, which keeps it lightweight.

# Start a GitHub Flow topic branch from up-to-date main
git switch main
git pull
git switch -c feature/login-throttle

3 GitLab Flow and Environment Branches

GitLab Flow extends a mainline workflow by adding branches that mirror deployment environments or releases. A common pattern is mainstagingproduction, where changes flow downstream only through merges.

This gives you an auditable record of exactly what is running in each environment: the tip of the production branch is what is in production. For products that cut versioned releases, you instead keep release/* branches and cherry-pick critical fixes into them.

The key rule is upstream first: a fix lands on main first, then is promoted downstream, so an environment never has a fix that main lacks.

# Promote reviewed changes from main into the staging branch
git switch staging
git merge --no-ff main

4 GitFlow: Release and Hotfix Branches

GitFlow is a heavier model built around two long-lived branches: main (released code) and develop (integration). Work happens on supporting branches:

  • feature/* — branched from and merged back to develop.
  • release/* — branched from develop to stabilise a version, then merged to main and back to develop.
  • hotfix/* — branched from main to patch production urgently, then merged to both main and develop.

GitFlow suits scheduled releases and software with explicit version numbers, but its many long-lived branches can slow down teams that deploy continuously.

# Open a release branch to stabilise version 2.4
git switch develop
git switch -c release/2.4.0

5 Trunk-Based Development

Trunk-based development minimises branching: all developers integrate into a single trunk (often main) at least daily, using very short-lived branches or committing directly behind feature flags.

Because everyone merges constantly, divergence stays small and merge conflicts are rare. Incomplete work is hidden behind flags rather than parked on a long branch. This style underpins high-frequency continuous delivery and pairs naturally with strong automated testing.

The trade-off: it demands discipline, fast reliable CI, and a culture of small, safe increments. Large risky changes must be broken down so the trunk always stays releasable.

# Ship incomplete work safely behind a flag, integrating daily
git switch -c short-lived-task
# ...small change guarded by a feature flag...
git switch main
git merge short-lived-task

6 Pull Request Best Practices

A pull request (PR) — called a merge request on GitLab — is a proposal to integrate a branch, plus a place to review and discuss it. Good PRs share traits that make review fast and safe:

  • Small and focused — one logical change is easier to review than a thousand-line dump.
  • Clear description — what changed, why, and how it was tested.
  • Green checks — CI passing before you ask humans to look.
  • Linked context — reference the issue or ticket it resolves.

Keeping the branch rebased or merged up to date with main avoids surprising the reviewer with unrelated conflicts.

# Push a branch and open a PR with the GitHub CLI
git push -u origin feature/login-throttle
gh pr create --fill

7 Draft Pull Requests and Early Feedback

A draft pull request signals that work is in progress and not yet ready to merge. Opening one early lets you trigger CI, share direction, and gather feedback before you have polished everything — without anyone accidentally merging unfinished code.

When the branch is ready, you mark the PR ready for review, which notifies reviewers and (with branch protection) unblocks merging. Draft PRs are ideal for spike work, large refactors you want eyes on early, or requesting design feedback.

Many teams pair drafts with a checklist in the description so contributors know what remains before requesting review.

# Open a draft PR, then mark it ready when finished
gh pr create --draft --title "WIP: payment retry"
gh pr ready

8 Effective Code Review

Code review catches defects, spreads knowledge and keeps a codebase consistent. Effective reviews focus on substance, not style nits a linter could catch.

  • Review the diff in context — understand what problem the change solves first.
  • Ask questions instead of issuing commands; assume good intent.
  • Distinguish blocking issues (correctness, security) from optional suggestions.
  • Approve when it is good enough to ship, not when it is theoretically perfect.

As an author, respond to every comment, keep follow-up commits small, and re-request review after addressing feedback. Tools like git range-diff help reviewers see what changed between pushes.

# Show what changed between two versions of a rebased branch
git range-diff main old-tip new-tip

9 Updating a Feature Branch: Merge vs Rebase

While your feature branch lives, main keeps moving. You have two ways to bring those new commits into your branch:

  • Merge main into your branch — creates a merge commit, preserves exact history, but clutters the branch with merge bubbles.
  • Rebase your branch onto main — replays your commits on top of the latest main, giving a linear history, but rewrites your branch’s commit hashes.

A common convention: rebase private feature branches for a clean history, but never rebase branches others have already pulled. Rebasing is the cleaner option for a tidy PR; merging is safer for shared branches.

# Replay your branch commits on top of the latest main
git fetch origin
git rebase origin/main

10 Interactive Rebase: Squash, Fixup, Reword

git rebase -i opens an editor listing the commits to replay, letting you reshape history before sharing it. Common actions:

  • pick — keep the commit as is.
  • reword — keep the change but edit the message.
  • squash — combine into the previous commit, merging both messages.
  • fixup — like squash but discards the squashed commit’s message.
  • drop — remove the commit entirely.

This is how you turn a messy “wip, wip, fix typo” series into a few clean, reviewable commits. Only do it on commits you have not yet shared, because it rewrites history.

# Interactively reshape the last 4 commits before opening a PR
git rebase -i HEAD~4

11 Autosquash with Fixup Commits

When addressing review feedback, you often want a small fix to fold into an earlier commit. Instead of remembering to squash manually, create a fixup commit that targets the original by hash. Later, --autosquash reorders and marks it automatically during an interactive rebase.

The flow is: git commit --fixup=<hash> creates a commit titled fixup! <original subject>. Running git rebase -i --autosquash <base> then positions it right after its target and pre-selects the fixup action, so you just save and exit. You can set rebase.autosquash true to make it the default.

# Create a fixup commit, then fold it in automatically
git commit --fixup=a1b2c3d
git rebase -i --autosquash a1b2c3d~1

12 Resolving Complex Merge Conflicts

A conflict arises when two branches change the same region of a file differently. Git marks the file with conflict markers: <<<<<<< for your side, ======= as a separator, and >>>>>>> for the incoming side.

For tricky cases, enable diff3 style, which also shows the common base version between markers. Seeing the original makes it far easier to reconcile both intentions rather than blindly picking a side.

After editing, stage the resolved files and continue the operation. Use git mergetool for a visual three-way view when text markers are not enough.

# Show the common ancestor in conflicts for clearer resolution
git config merge.conflictStyle diff3
git add resolved-file.txt

13 rerere: Reuse Recorded Resolution

rerere stands for reuse recorded resolution. When enabled, Git records how you resolved a particular conflict. If the same conflict appears again — common during long rebases or repeated merges of a long-lived branch — Git automatically replays your earlier resolution.

This is a huge time-saver when you rebase a feature branch repeatedly onto a moving main and keep hitting the same conflicting hunk. You enable it once globally and forget about it; Git stores resolutions in .git/rr-cache.

It does not auto-resolve anything you have not solved before — it only reuses resolutions you previously recorded.

# Enable reuse of recorded conflict resolutions
git config --global rerere.enabled true

14 rebase --onto for Surgical Replays

git rebase --onto lets you replay a precise range of commits onto a new base, independent of where they currently sit. Its form is git rebase --onto <newbase> <upstream> <branch>: it takes the commits reachable from branch but not from upstream and replants them on newbase.

Classic uses include moving a feature branch that was accidentally based on the wrong parent, or extracting just the last few commits to sit on top of a different branch. It is the scalpel of rebasing — surgical compared to a plain git rebase.

# Move work branched off the wrong parent onto main
# Replay commits after 'wrong-base' onto 'main'
git rebase --onto main wrong-base feature

15 Cherry-Picking Commits and Ranges

git cherry-pick applies the change introduced by one or more existing commits onto your current branch, creating new commits with new hashes. It is ideal for porting a specific bug fix from one branch to another without merging everything else.

You can pick a single commit, several listed commits, or a range with A..B (which excludes A itself) or A^..B (which includes A). If a pick conflicts, resolve it and run git cherry-pick --continue.

Because each pick is a fresh commit, the same change can appear with different hashes on multiple branches.

# Port a single fix, then a range, onto the current branch
git cherry-pick 9fceb02
git cherry-pick a1b2c3d..f4e5d6c

16 Reverting a Merge Commit

Reverting a normal commit is straightforward, but a merge commit has two parents, so Git cannot guess which side to keep. You must tell it with -m, the 1-based parent number to treat as the mainline.

Usually -m 1 means “keep the branch I was on (the first parent) and undo what the merge brought in”. The revert creates a new commit that backs out the merged changes without rewriting history.

A subtle catch: once you revert a merge, simply merging the same branch again will not re-introduce the changes, because Git thinks they are already present. To re-merge later you must first revert the revert.

# Undo a merge, keeping the first-parent mainline
git revert -m 1 <merge-commit-sha>

17 Forks and Syncing with Upstream

In open-source and cross-team work you often fork a repository: you make a personal server-side copy you can push to, then propose changes back via pull requests. Your fork is origin; the original project is conventionally the upstream remote.

Because the original keeps evolving, you periodically sync: fetch from upstream and integrate its main into yours, then push to your fork. Keeping your fork current minimises conflicts when your PR is reviewed.

# Add upstream, then sync your fork's main with it
git remote add upstream https://github.com/original/project.git
git fetch upstream
git switch main
git merge upstream/main

18 Managing Multiple Remotes

A repository can track several remotes at once — for example origin (your fork), upstream (the source project), and a mirror for backups. Each remote is just a named set of URLs.

You can list, inspect, rename, change the URL of, or remove remotes. Fetching from a specific remote updates only its remote-tracking branches, so you control exactly what enters your repository. This is essential when collaborating across organisations or migrating between hosts.

# Inspect and tidy your configured remotes
git remote -v
git remote set-url origin git@github.com:me/project.git
git remote rename mirror backup

19 Protected Branches and Required Checks

Branch protection is a server-side policy (on GitHub, GitLab, etc.) that guards important branches like main. Typical rules include:

  • Disallow direct pushes — changes must arrive via pull request.
  • Require a minimum number of approving reviews.
  • Require status checks (CI, tests, linters) to pass before merging.
  • Require the branch to be up to date with the base before merge.
  • Forbid force-pushes and branch deletion.

These rules are enforced by the hosting platform, not by your local Git, so they protect the shared history even if a contributor tries to bypass them locally.

# Direct push to a protected branch is rejected by the server
git push origin main
# remote: error: GH006: Protected branch update failed

20 Semantic Versioning in Practice

Semantic Versioning (SemVer) gives releases a meaningful MAJOR.MINOR.PATCH number:

  • MAJOR — incompatible API changes (breaking).
  • MINOR — new functionality added in a backward-compatible way.
  • PATCH — backward-compatible bug fixes.

Pre-release suffixes like -rc.1 mark unstable candidates. Communicating compatibility through the number lets consumers upgrade safely. In Git you encode releases as tags such as v2.4.1, which tools and package managers read to resolve versions.

# Tag a backward-compatible feature release under SemVer
git tag -a v2.4.0 -m "Release 2.4.0: add export API"

21 Annotated vs Lightweight Tags

Git has two kinds of tags. A lightweight tag is just a name pointing at a commit — like a branch that never moves. An annotated tag is a full object storing the tagger, date, a message, and optionally a GPG signature.

For releases you almost always want annotated tags, because they carry provenance and can be signed and verified. Use git tag -a (or -s to sign). Remember that git push does not send tags by default — push them explicitly.

# Create an annotated, then push tags to the remote
git tag -a v1.0.0 -m "First stable release"
git push origin --tags

22 Line Endings and .gitattributes

Windows uses CRLF line endings while Unix uses LF, which can cause spurious whole-file diffs in mixed teams. The core.autocrlf setting can convert on checkout/commit, but a more robust, repository-wide fix is a .gitattributes file.

Declaring * text=auto tells Git to normalise text files to LF in the repository while checking them out with the platform’s convention. You can force specific types, e.g. *.sh text eol=lf or mark binaries with -text so Git never touches them. Because .gitattributes is committed, every contributor gets the same behaviour.

# Normalise line endings repo-wide via .gitattributes
echo "* text=auto" >> .gitattributes
git add --renormalize .

23 Git LFS for Large Files

Git stores every version of every file, so committing large binaries (videos, datasets, design assets) bloats the repository forever. Git Large File Storage (LFS) solves this by storing such files on a separate server and keeping only a small pointer in the Git history.

You install the extension, tell it which patterns to track (which writes rules into .gitattributes), and commit normally. When you clone or pull, LFS fetches the real content on demand. This keeps clones fast and history lean while still versioning big files.

# Track large assets with LFS
git lfs install
git lfs track "*.psd"
git add .gitattributes design.psd

24 Shallow and Partial Clones

For huge repositories you may not need the entire history. A shallow clone with --depth fetches only the most recent commits, dramatically reducing download size — ideal for CI builds that only need the current state.

A partial clone with --filter=blob:none skips file contents up front and fetches blobs lazily when you actually access them, giving full history with a small initial download. You can later deepen a shallow clone with git fetch --unshallow if you need more history.

# Fast CI checkout: only the latest commit
git clone --depth 1 https://github.com/example/big-repo.git

25 Parallel Work with git worktree

git worktree lets one repository check out multiple branches at once in separate directories, all sharing the same object store. Instead of stashing and switching to handle an urgent fix, you create a second working tree for the hotfix branch and keep your feature work untouched.

Each worktree has its own HEAD and index, so builds and tests can run in parallel without interfering. When finished, you remove the worktree. The same branch cannot be checked out in two worktrees simultaneously, which prevents conflicting edits.

# Spin up a separate working directory for a hotfix
git worktree add ../project-hotfix hotfix/urgent
git worktree list

26 Forensics with Log and Blame

When investigating when and why something changed, Git offers powerful forensic tools. git log -S<string> (the “pickaxe”) finds commits that added or removed a given string. git log -L follows the history of specific lines in a file.

git blame annotates each line with the commit, author and date that last changed it — use -C to detect lines moved or copied from elsewhere so a refactor does not hide the real origin. Together these pinpoint the exact change that introduced a behaviour.

# Find every commit that touched the text 'retryLimit'
git log -S retryLimit --oneline

27 Finding Regressions with git bisect

git bisect performs a binary search through history to find the commit that introduced a bug. You mark a known-bad commit and a known-good one; Git repeatedly checks out the midpoint for you to test, halving the search space each step.

You can automate it: git bisect run <script> runs a test command at each step, interpreting exit code 0 as good and non-zero as bad, and converges to the first bad commit with no manual input. When done, git bisect reset returns you to where you started.

# Automatically locate the commit that broke the tests
git bisect start HEAD v1.8.0
git bisect run ./run-tests.sh

28 Aliases and Useful Config

Git is highly configurable. Aliases turn long or frequent commands into short ones, saving keystrokes and standardising team habits. They live in your config and can even shell out with a leading !.

Beyond aliases, settings like pull.rebase, push.autoSetupRemote, init.defaultBranch and core.editor shape your daily workflow. Config is layered: system, then global (per-user), then local (per-repo), with the most specific level winning. A small set of well-chosen aliases makes everyone faster.

# Define a handy one-line graph log alias
git config --global alias.lg "log --oneline --graph --decorate --all"
git lg

29 Client-Side Hooks and pre-commit

Client-side hooks are scripts Git runs at points in your local workflow, such as pre-commit (before a commit is recorded) and commit-msg (to validate the message). They let you lint, format, or run quick tests before bad code enters history.

Native hooks live in .git/hooks and are not committed, so teams use the pre-commit framework — a tool configured by a committed .pre-commit-config.yaml — to share and version hooks across everyone. Because client hooks run locally, they are advisory; server-side checks remain the real gate.

# Install and run the pre-commit framework's hooks
pre-commit install
pre-commit run --all-files

30 Mastering .gitignore Patterns

The .gitignore file uses glob patterns to keep generated and local files out of version control. Beyond basics, a few rules matter:

  • A leading / anchors the pattern to the directory of the .gitignore.
  • A trailing / matches directories only.
  • ** matches across directory levels, e.g. logs/**/*.tmp.
  • A leading ! negates a rule to re-include something otherwise ignored.

Ignoring only affects untracked files; a file already tracked stays tracked until you git rm --cached it. Order matters because later patterns can override earlier ones.

# Ignore a folder but keep one file inside it
printf 'build/\n!build/keep.txt\n' >> .gitignore

31 Conventional Commits in Practice

Conventional Commits is a lightweight convention for commit messages that machines can parse. The subject follows type(scope): description, where type is one of feat, fix, docs, refactor, test, chore and more.

A feat maps to a SemVer MINOR bump and a fix to a PATCH. A breaking change is flagged with a ! after the type or a BREAKING CHANGE: footer, signalling a MAJOR bump. Because the format is structured, tools can auto-generate changelogs and compute the next version automatically.

# A conventional commit; the ! marks a breaking change
git commit -m "feat(api)!: drop deprecated v1 export endpoint"

32 Designing Your Team’s Workflow

The strategies and tools in this course combine into a single, deliberate workflow. Designing one means making explicit choices and writing them down so everyone follows the same path.

  • Integration model — mainline (GitHub Flow / trunk) or branch-heavy (GitFlow)?
  • History policy — squash-merge for a clean log, or merge commits to preserve detail?
  • Protection — required reviews and green checks before merge.
  • Releases — SemVer tags, annotated and signed, with conventional commits driving changelogs.

A good workflow is the simplest one that keeps main always releasable while letting the team move fast. Document it in CONTRIBUTING.md so it is discoverable and enforceable.

# Enforce a clean, linear history by squashing PRs on merge
gh pr merge --squash --delete-branch

🎓 Certificate of Completion

🔒 Complete every lesson quiz above with 90%+ to unlock your downloadable certificate.