What is Version Control — and Why Is Git Distributed?
Before distributed version control, teams used centralized systems (CVS, Subversion) where a single central server held the only complete history — every commit, log, and diff required a network round-trip, and if that server went down, nobody could commit, branch, or even browse history. Git's core design decision was to give every clone the full history, not just a working snapshot — which is exactly what makes branching, merging, and working offline cheap, local operations instead of server round-trips.
// BEFORE — centralized VCS: branching means a server-side copy
$ svn copy https://svn.example.com/trunk https://svn.example.com/branches/feature-x
# Every commit, every log, every diff round-trips to the server.
# No server reachable = no history, no commit, no branch at all.
// AFTER — Git: the full history lives on your machine
$ git clone https://github.com/shop/order-service.git
$ git checkout -b feature/discount-codes
# Branching is a 41-byte pointer update — no network call, no server.
# You can commit, diff, and browse the entire project history offline.
Initial Setup
# Identity — required once per machine
git config --global user.name "Your Name"
git config --global user.email "your.email@example.com"
# Default branch name for new repositories
git config --global init.defaultBranch main
# View all effective configuration
git config --list
Repository Creation
# Initialize a new repository
cd order-service
git init
# Clone an existing repository
git clone https://github.com/shop/order-service.git
# Clone a specific branch only
git clone -b develop https://github.com/shop/order-service.git
Line Endings — .gitattributes, Not Per-Developer Config
core.autocrlf is a common recommendation, but it's a
per-developer, per-machine setting — it only
works if every single teammate configures it the same way, on
every machine, forever. A committed .gitattributes
file enforces the same normalization for everyone automatically,
the same way .editorconfig does for formatting (see
IDEs).
# .gitattributes — committed at repo root
* text=auto eol=lf
*.jar binary
*.bat text eol=crlf
.gitignore for Java Projects
The .gitignore file tells Git which files to never
stage — compiled output, IDE metadata, and local secrets that
have no business in shared history.
# Compiled output
*.class
*.jar
*.war
*.ear
# Maven
target/
# Gradle
.gradle/
build/
!gradle/wrapper/gradle-wrapper.jar
# IntelliJ IDEA
.idea/
*.iml
# Eclipse
.settings/
.classpath
.project
# VS Code
.vscode/
# OS files
.DS_Store
Thumbs.db
# Local secrets and environment overrides — see Section 8 for why
# .gitignore alone is not sufficient protection for these
.env
application-local.properties
Keep truly personal, machine-specific ignores (your own
editor swap files, for example) out of the repo's shared
.gitignore entirely:
git config --global core.excludesfile ~/.gitignore_global
Branching Strategies — Match the Model to How You Actually Deploy
This is where most teams copy a diagram without asking whether it fits their release process. There are three real options, and picking the wrong one adds process overhead that buys you nothing.
Trunk-Based Development — the default for continuous deployment
One long-lived branch (main), short-lived feature
branches merged back within a day or two, behind feature flags
if the work isn't ready to ship. This is the correct default for
a service deployed continuously — which describes most of the
microservices covered throughout this site's Application Servers
and CI/CD topics.
# Trunk-based: branch, commit, merge back same day
git checkout main
git pull
git checkout -b add-discount-codes
# ... small, focused change ...
git push -u origin add-discount-codes
# Open PR, get one review, merge within hours — not days
GitHub Flow — trunk-based with an explicit PR gate
main # always deployable
├── feature/login # short-lived
├── fix/null-pointer # short-lived
└── docs/readme-update # short-lived
# 1. Branch from main
git checkout -b feature/discount-codes main
# 2. Commit
git add .
git commit -m "feat(order): add percentage discount codes"
# 3. Push and open a Pull Request
git push -u origin feature/discount-codes
# 4. After review and CI passes, merge to main — deploy follows automatically
Git Flow — reserve it for versioned releases, not continuous services
Git Flow's develop/release/*/hotfix/*
branch structure exists to support multiple released
versions in parallel — a library or an on-premise
product where customers run v1.4 and v2.1 simultaneously and
both need bug fixes. Forcing this model onto a continuously
deployed service adds a long-lived develop branch
that constantly drifts from what's actually in production, for
no benefit — there's only ever one "release" running.
main (or master) # production-ready code
├── develop # integration branch
│ └── feature/xyz
├── release/1.0 # release stabilization
└── hotfix/urgent # production patch, branched from main
Use trunk-based / GitHub Flow when: the
service deploys continuously and only one version is ever
live in production. Use Git Flow when: you
genuinely maintain multiple released versions in parallel and
need a stabilization window before each release. Most
microservices in an e-commerce platform fall into the first
category — order-service doesn't have a "v1.4
still in the field" problem the way a distributed client
library does.
Merging and Rebasing
Merge — preserves complete history
git checkout main
git merge feature/discount-codes
# Creates a merge commit — both parent histories remain intact
git merge --abort # bail out of a merge in progress
Rebase — linear history, rewrites commits
git checkout feature/discount-codes
git rebase main
# Interactive rebase — squash the last 3 commits into one clean commit
git rebase -i HEAD~3
git rebase --continue # after resolving a conflict
git rebase --abort
--force-with-leaseRebasing rewrites commit hashes. If anyone else has already pulled the branch, their history now diverges from yours irreconcilably. When you do need to update a remote branch after a legitimate rebase of your own unshared work, use:
# DANGEROUS — overwrites the remote unconditionally,
# silently discarding any commit a teammate pushed since your last fetch
git push --force
# SAFE — fails instead of overwriting if the remote has moved
# since you last fetched it
git push --force-with-lease
--force-with-lease checks that the remote branch
still points where you last saw it before overwriting it. A
plain --force has no such check — it's the
single most common way a teammate's pushed commit
silently vanishes from a shared branch.
Resolving Conflicts
# Conflict markers inside the file
<<<<<<< HEAD
public void processOrder(Order order) {
// your changes
=======
public void processOrder(Order order, Customer customer) {
// their changes
>>>>>>> feature/order-validation
# 1. Edit the file to keep the correct combined logic
# 2. Remove the conflict markers entirely
# 3. Stage the resolved file
git add src/main/java/com/shop/order/OrderService.java
# 4. Continue
git commit # for a merge
git rebase --continue # for a rebase
Common Git Operations
Viewing History
git log --oneline --graph --all # compact, visual
git log -p # show the diff of each commit
git log --author="Karlete"
git log --since="2026-01-01" --until="2026-06-30"
git blame OrderService.java # who changed each line, and when
Undoing Changes
git restore OrderService.java # discard working-directory changes
git restore --staged OrderService.java # unstage without discarding the edit
git commit --amend -m "fix(order): correct total calculation"
git reset --soft HEAD~1 # undo last commit, keep changes staged
git reset --hard HEAD~1 # undo last commit, discard changes entirely
git revert abc123 # undo via a new commit — safe on shared branches
reset --hard vs revert on a shared branchreset --hard rewrites history — the same
force-push danger from Section 4 applies if the branch is
shared. revert adds a new commit that undoes
the change, leaving history intact and safe to push normally.
On main, always revert; reserve
reset for commits nobody else has seen.
Stashing and Tags
git stash # shelve work in progress
git stash pop # reapply the most recent stash
git stash apply stash@{2} # reapply a specific one, keep it in the list
git tag -a v1.4.0 -m "Release 1.4.0" # annotated — always prefer this over a lightweight tag
git push origin v1.4.0
Working with Remotes
git remote -v
git remote add upstream https://github.com/original-org/order-service.git
git fetch origin # download refs — no merge, safe to run anytime
# Sync a fork with its upstream
git fetch upstream
git checkout main
git merge upstream/main
git push origin main
Commit Practices — Message Format and Signing
Conventional Commits
type(scope): subject
# Types:
# feat: new feature
# fix: bug fix
# refactor: restructuring without behavior change
# test: adding or fixing tests
# chore: build tasks, dependencies, configs
feat(order): add percentage discount codes
fix(payment): handle null payment method gracefully
Fixes #123
refactor(customer): extract validation into CustomerValidator
BREAKING CHANGE: CustomerService now requires an AddressValidator
Conventional Commits are not just a style preference — many teams
wire semantic-release or similar tooling into CI to
derive the next version number and changelog directly from
commit types, which only works if the format is followed
consistently. See CI/CD Basics.
Signing Commits — verifying who actually authored a commit
user.name and user.email are plain text
— anyone can set git config user.email
"you@company.com" and author a commit that appears to be
yours. Signing proves the commit was created by someone holding
a specific private key, and GitHub/GitLab display a "Verified"
badge only for signed commits. This connects directly to the
supply-chain concerns covered in
Authentication vs
Authorization — a commit is, in effect, an unauthenticated
claim unless it's signed.
# SSH signing (simplest — reuses your existing SSH key)
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true
# GPG signing (traditional)
git config --global user.signingkey ABCD1234
git config --global commit.gpgsign true
# Sign a single commit explicitly
git commit -S -m "feat(order): add percentage discount codes"
- One logical change per commit — atomic, not a dump of the day's work
- Present tense subject line ("add feature", not "added feature")
- Reference the issue/ticket number when one exists
- Enable signing globally once, rather than remembering
-Sper commit
Git Hooks and Keeping Secrets Out of History
.gitignore only stops a file from being staged
going forward. It does nothing for a secret
that was already committed once — that credential is in history
permanently, retrievable by anyone with clone access, even after
you delete the file and add it to .gitignore
afterward.
// WRONG assumption — this does NOT remove the secret from history
$ echo "application-local.properties" >> .gitignore
$ git rm --cached application-local.properties
$ git commit -m "Remove leaked credentials"
# The old commit that added the file with the real password is still
# reachable in history — git log -p will show it, forever, to anyone
# who clones the repo.
Pre-commit hooks — catch it before it's ever committed
# .git/hooks/pre-commit — a simple local guard (not committed by default;
# use the "pre-commit" framework or huskyesque tooling to share it with the team)
#!/bin/sh
git diff --cached --name-only | xargs grep -lE "AKIA[0-9A-Z]{16}" && {
echo "AWS key detected in staged changes — commit blocked."
exit 1
}
In practice, use a maintained scanner rather than hand-rolled
regex — gitleaks or git-secrets
run as a pre-commit hook and as a CI step, catching both new
commits and, when run with --log-opts against full
history, anything that slipped through previously.
If a real credential was pushed, the correct first response
is to revoke and rotate that credential at
the source (the database, the cloud provider, the API
vendor) — treat it as compromised the moment it's on a
remote you don't fully control the audit log for.
Rewriting history with git filter-repo
afterward is good hygiene, but it does not undo exposure
that already happened, and every existing clone still has
the old commit until they re-clone.
Advanced Git Commands
Partial staging — genuinely atomic commits
# You fixed a bug AND refactored a method in the same file.
# Stage only the bug fix hunk, leave the refactor for a separate commit.
git add -p OrderService.java
# y = stage this hunk, n = skip it, s = split it further
Worktrees — two branches checked out at once, no stash needed
# Reviewing a PR while mid-feature on another branch, without stashing
git worktree add ../order-service-review feature/discount-codes
# A second, independent working directory — same repo, different branch,
# both checked out simultaneously.
git worktree remove ../order-service-review # when done
Bisect — automated, not manual
git bisect start
git bisect bad # current commit is broken
git bisect good v1.4.0 # this tag was known good
# Automate it with a script that exits 0 (good) or 1 (bad) —
# git tests every candidate commit for you, no manual checkout loop
git bisect run mvn -q test -Dtest=OrderServiceTest
git bisect reset
Other essentials
git cherry-pick abc123 # apply one specific commit onto the current branch
git log -S "applyDiscount" --source --all # find every commit that added/removed this string
git clean -nd # dry-run: show untracked files/dirs that would be removed
git clean -fd # actually remove them
git reflog # recover a branch tip even after a hard reset or deletion
git checkout -b recovered-branch abc123
Git in Java IDEs
IntelliJ IDEA
Ctrl+K # Commit dialog
Ctrl+Shift+K # Push
Alt+9 # Git tool window
# Visual diff, interactive rebase, and conflict resolution UI all built in.
# "Shelve" is IntelliJ's own equivalent to git stash, independent of it.
Eclipse (EGit)
# Team menu (right-click project) → Commit / Push / Pull / Switch To
See IDEs for how each IDE's Git integration relates to its underlying Java semantic engine.
GitHub/GitLab Workflow
Pull Request / Merge Request
git checkout -b feature/new-shipping-rate main
git add .
git commit -m "feat(shipping): add flat-rate shipping tier"
git push -u origin feature/new-shipping-rate
# After review and CI, merge via the UI or the CLI
gh pr merge --squash
Merge Strategy — the choice affects what git log looks like forever
| Strategy | Result | When to use |
|---|---|---|
| Squash merge | All PR commits collapse into one commit on main | Default for feature branches with messy WIP commits — keeps main's history clean and one-commit-per-feature |
| Merge commit | Preserves every commit plus a merge commit | When individual commits inside the PR are each meaningful and reviewed separately |
| Rebase merge | PR commits replay individually onto main, no merge commit | Small PRs where a fully linear history matters more than commit grouping |
CODEOWNERS — route review automatically, don't rely on memoryA CODEOWNERS file at the repo root maps paths to
required reviewers, so a change under
src/main/java/com/shop/payment/** automatically
requests the payments team, without anyone having to
remember to tag them by hand.
# .github/CODEOWNERS
/src/main/java/com/shop/payment/ @shop/payments-team
/src/main/java/com/shop/order/ @shop/order-team
Best Practices and Common Pitfalls
✅ Do
- Match the branching model to the deployment model — trunk-based/GitHub Flow for continuously deployed services, Git Flow only for genuinely versioned parallel releases
- Use
git push --force-with-lease, never a plain--force, on any branch someone else might have touched - Commit a
.gitattributesfile rather than relying on every developer configuringcore.autocrlfidentically - Run a secret scanner (gitleaks, git-secrets) as both a pre-commit hook and a CI step
- Sign commits (SSH or GPG) on any repo where authorship needs to be verifiable, not just claimed
- Use
git revertinstead ofgit reset --hardto undo anything already pushed to a shared branch
❌ Don't
- Don't rebase or force-push a branch that others have already pulled from — their history diverges irreconcilably
- Don't assume adding a file to
.gitignoreafter the fact removes it, or any secret it contained, from history - Don't impose Git Flow's
developbranch on a service deployed continuously — it becomes a permanently drifting branch nobody trusts - Don't leave a leaked credential in place after "fixing" the commit — rotate it at the source; history rewriting alone does not undo the exposure
- Don't bundle unrelated changes into one commit — use
git add -pto split them before committing
Interview Questions
Q: What's the difference between git fetch and git pull?
git fetch downloads the remote's commits and updates
your local tracking branches, but does not touch your working
directory or current branch. git pull is
fetch followed immediately by a
merge (or rebase, if configured) of
the remote branch into your current one. fetch is
always safe to run; pull changes your working
files.
Q: What is the difference between git merge and git rebase?
Merge combines two branches' histories with a new merge commit
that has two parents — nothing is rewritten, the full history is
preserved. Rebase replays your branch's commits one by one onto
the tip of another branch, producing a linear history but
generating new commit hashes for every replayed commit — which
is why rebasing a branch already pushed and shared is
dangerous.
Q: Why isn't adding a leaked password to .gitignore enough to remove it?
.gitignore only prevents files matching a pattern
from being staged from that point forward. A commit
that already exists in history still contains the old version
of the file with the real credential — anyone who clones the
repository or runs git log -p on that commit can
still see it, indefinitely.
Q: A teammate reports their pushed commit disappeared from a shared feature branch after you force-pushed. What happened, and how do you prevent it going forward?
A plain git push --force unconditionally overwrites
whatever is on the remote with your local branch tip — it
performs no check on what the remote currently points to. If your
teammate pushed a commit after your last fetch,
your force-push silently discarded it; the commit still exists
in Git's object database and is recoverable via
reflog on their machine if they still have the
local branch, but it's gone from the shared remote. The fix is
to always use git push --force-with-lease, which
refuses to push if the remote branch has moved since you last
fetched it — it converts a silent, destructive overwrite into a
visible failure the team can investigate before anything is
lost.
Q: Your organization mandates Git Flow for every repository, including a continuously deployed Spring Boot microservice with no versioned releases. What's the actual cost of this, beyond "it's more branches"?
Git Flow's value proposition is coordinating stabilization
across multiple versions that are simultaneously live in the
field — a develop branch that accumulates
completed features ahead of a release/*
branch that stabilizes before tagging. A continuously deployed
service has no such scenario: there is exactly one version
running in production at any time, deployed directly from
main on every merge. Forcing Git Flow here creates
a develop branch that is neither "what's in
production" nor "what's released" — it's a third, permanently
drifting state that CI either has to build and deploy
redundantly, or that nobody actually deploys from, making it
dead process weight. The mismatch also breaks the mental model
of trunk-based CI/CD, where every merge to main
is assumed deployable — under Git Flow that assumption shifts
to develop, which then requires its own separate
CI pipeline to keep meaningful.
Q: Explain concretely why signing commits matters even inside a private, access-controlled repository.
git config user.email is plain, unauthenticated
text — anyone with commit access (a compromised CI credential, a
disgruntled contractor, an attacker who obtained a valid push
token) can set it to any value and author commits that display
as coming from someone else entirely, with no cryptographic
proof required. Access control on the repository restricts
who can push, but says nothing about whose identity
a given commit actually represents once someone has push
access through any means. Signing with a GPG or SSH key ties
each commit to a specific private key the platform can verify
against a registered public key — the "Verified" badge is
exactly that check. This matters most precisely in the
scenario people assume doesn't need it: a compromised CI token
with push rights can forge commits as any teammate unless
signing is enforced, at which point it can only push commits
that fail verification, which is a detectable, actionable
signal instead of a silent impersonation.