Version Control — Git for Java Projects

Why Git is distributed, choosing a branching model that matches how you actually deploy, the force-push trap, commit signing, and keeping secrets out of history in the first place

← Back to Index

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
Global .gitignore for OS/IDE noise

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
Choosing between them is not a style preference

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
Never rebase a branch that has been pushed and shared — and never force-push without --force-with-lease

Rebasing 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 branch

reset --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"
Good Commit Practices
  • 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 -S per 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 secret was already committed, rotate it — don't just rewrite history

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

StrategyResultWhen to use
Squash mergeAll PR commits collapse into one commit on mainDefault for feature branches with messy WIP commits — keeps main's history clean and one-commit-per-feature
Merge commitPreserves every commit plus a merge commitWhen individual commits inside the PR are each meaningful and reviewed separately
Rebase mergePR commits replay individually onto main, no merge commitSmall PRs where a fully linear history matters more than commit grouping
CODEOWNERS — route review automatically, don't rely on memory

A 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 .gitattributes file rather than relying on every developer configuring core.autocrlf identically
  • 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 revert instead of git reset --hard to 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 .gitignore after the fact removes it, or any secret it contained, from history
  • Don't impose Git Flow's develop branch 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 -p to split them before committing

Interview Questions

🎓 Junior level

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.

🔥 Senior level

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.