Skip to content
SparkTime, home
Java From Zero to Job-Ready
Lessons
1 · Java Foundations
2 · Object-Oriented Java
3 · Core Java
4 · Collections and Generics
5 · Functional Java
6 · The JVM and Concurrency
7 · Tools and Craft
8 · Databases and JDBC
9 · Web and Spring
10 · Persistence with JPA
11 · Secure, Tested, Production-Ready
12 · Interviews and Readiness
Lesson 24 of 41

Lesson 24 · Tools and Craft

Git for Java Projects: Commits, Branches, Merging and Pull Requests

A practical Git workflow for Java developers: the three areas, commits and good messages, branches, merging and resolving conflicts, pull requests, a correct .gitignore for Maven projects, and what to do when a secret is committed.

On this page

Key takeaways

  • Git records snapshots of your project. Changes move from the working tree to the staging area (git add) to the repository (git commit), and each commit has a message that explains why.
  • Branches are cheap, so do each piece of work on its own branch and merge it back through a reviewed pull request.
  • A merge conflict means two branches changed the same lines. Git marks both versions in the file, you decide the final text, then add and commit.
  • .gitignore keeps build output, IDE files and secrets out of the repository, but it does not untrack files that were already committed.
  • A committed secret is compromised. Revoke or rotate it first, because removing it from history is secondary.

Imagine renaming a file Report-final-v2-REALLY-final.docx. Version control exists so you never need to. Git is the version control system used by almost every software team: it records the history of your project, lets you try ideas safely on separate branches, and lets many people change the same code without overwriting each other.

This article teaches the part of Git you will use every day on a Java project, with a real example (including a real merge conflict) that was run for this article.

What you will learn

#
  • The three areas of Git and the core commands.
  • How to write a useful commit and how to work on branches.
  • How to merge, and how to resolve a conflict step by step.
  • How pull requests and code review fit in.
  • What to put in .gitignore, and what to do when a secret is committed.

How Git thinks

#

Git stores your project as a series of commits. A commit is a snapshot of all tracked files, with an author, a time, a message, and a pointer to its parent commit(s). Together the commits form the history.

Your changes pass through three areas:

Area

What it is

Moves on with

Working tree

The files on disk that you edit

Staging area (index)

The set of changes you have chosen for the next commit

git add

Repository

The saved history of commits

git commit

The staging area is useful because you choose exactly what goes into each commit, so one commit can be one logical change even if you edited several things.

Git works locally: your computer has the full history. A remote (such as one on GitHub, GitLab or Bitbucket) is another copy used to share work, reached with git push and git pull.

Older style. Earlier version control systems such as CVS and Subversion were centralised: one server held the history, and you needed it to commit. You may meet Subversion in old company projects. Git is distributed, which makes branching and working offline practical.

First steps

#
Shell
git config --global user.name "Your Name"git config --global user.email "you@example.com"git init                      # start a repository in this foldergit status                    # what changed? (use this constantly)git add src/                  # stage changesgit commit -m "Add Greeting class"git log --oneline             # compact historygit diff                      # unstaged changes; git diff --staged shows staged ones

Prefer git add <paths> over git add -A or git add . while you are learning, so you do not stage files you did not intend to (generated output, local settings, secrets). Check git status before every commit.

Good commits

#

A commit should be one logical change that leaves the project working (the build and tests pass). The message explains the why so that someone reading the history in a year understands it.

  • A short summary line, in the imperative mood: "Add validation for blank names" rather than "added stuff". About 50 characters or fewer is the common convention.
  • If needed, a blank line and a body saying why the change was made and what trade-offs it involves.
  • Not "fix", "update" or "WIP" alone.
  • Small commits are easier to review, to revert, and to understand than one giant commit at the end of the week.

Branches

#

A branch is a movable pointer to a commit. Creating one costs almost nothing, so the normal workflow is: one branch per piece of work, and main always stays in a working state.

Shell
git switch -c feature/shout     # create a branch and move to it# ...edit, add, commit...git switch main                 # go backgit branch                      # list branches

Older style. Tutorials often use git checkout -b feature/x and git checkout main. They still work. Newer Git added git switch (change branch) and git restore (discard changes to files), because checkout did too many different things.

Name branches so the purpose is obvious: feature/expense-export, fix/null-category. Teams often include a ticket number.

Merging

#

When the work is finished, you merge the branch back. If main has not moved since you branched, Git just moves the pointer forward (a fast-forward). If both branches have new commits, Git creates a merge commit that has two parents.

Here is a real, run example. The repository has a Greeting class on main. One branch changes the text to "HELLO", while at the same time main changes it to "Hello, world". Because both branches changed the same line, this will conflict:

Code
$ git log --oneline --graph --all* 7df0322 Shout the greeting| * 4078fc9 Greet the world|/* d0d33fe Add Greeting class$ git merge feature/shoutAuto-merging src/main/java/app/Greeting.javaCONFLICT (content): Merge conflict in src/main/java/app/Greeting.javaAutomatic merge failed; fix conflicts and then commit the result.

(The commit hashes on your machine will be different.) The graph shows the two lines of work that split from the same starting commit.

Resolving a conflict

#

A conflict is not an error in Git. It means Git cannot decide for you, because two people changed the same lines. git status --short shows UU (unmerged, changed on both sides) next to the file. Open it and you will see markers:

Code
public class Greeting {<<<<<<< HEAD    String text() { return "Hello, world"; }=======    String text() { return "HELLO"; }>>>>>>> feature/shout}
  • Between <<<<<<< HEAD and ======= is your current branch's version (here main).
  • Between ======= and >>>>>>> feature/shout is the incoming branch's version.

Your job is to edit the file to the final correct text, which might be one side, the other, or a combination, and delete all three marker lines. Here we want both changes, so the result is:

Java
public class Greeting {    String text() { return "HELLO, WORLD"; }}

Then tell Git it is resolved and finish the merge:

Code
$ git add src/main/java/app/Greeting.java$ git commit -m "Merge feature/shout"$ git log --oneline --graph --all*   9379264 Merge feature/shout|\| * 7df0322 Shout the greeting* | 4078fc9 Greet the world|/* d0d33fe Add Greeting class

How to resolve conflicts calmly:

  1. Understand both changes before choosing. If you do not know why the other side changed it, ask them.
  2. After resolving, build and run the tests. A merge can compile but be logically wrong. A conflict-free merge can also break behaviour, so tests matter even when Git raises no conflict.
  3. IDEs (IntelliJ IDEA, VS Code) have three-way merge tools that show both sides and the result. They are convenient, and the underlying idea is the same.
  4. To abandon a merge and go back, use git merge --abort.
  5. Keep branches short-lived and merge main into them (or rebase) often, so conflicts stay small.

Rebase, briefly

#

git rebase main is an alternative to merging: it replays your commits on top of the latest main, giving a straight history without a merge commit. It rewrites your commits (new hashes). The rule to remember: never rebase commits that other people have already pulled, because you rewrite history they depend on. Rebasing your own unpublished branch is fine. Teams differ on merge versus rebase, so follow the team's convention.

Remotes, push, pull and pull requests

#
Shell
git remote add origin <url-of-your-empty-remote-repository>git push -u origin main         # first push, sets the upstreamgit pull                        # fetch and integrate changes from the remotegit clone <url>                 # get a copy of an existing repository

On a team, the usual flow is:

  1. Update main: git switch main then git pull.
  2. Create a branch: git switch -c feature/expense-export.
  3. Make small commits. Run the build and tests (mvn clean verify) before you push.
  4. git push -u origin feature/expense-export.
  5. Open a pull request (called a merge request on GitLab): a request to merge your branch into main, with a description, a diff, and a place for discussion.
  6. Code review: teammates read the change, comment, and approve. Automated checks (CI) run the build and tests.
  7. Address the comments by pushing more commits to the same branch, then merge.
  8. Delete the branch and update your local main.

A good pull request is small (a reviewer can understand it in minutes), focused (one purpose), explained (why, how to test it, anything risky) and passing the automated checks before you ask for review. When you review others, comment on the code and its consequences, not the person; ask questions, and check tests, error handling, naming and security.

.gitignore for Java and Maven

#

A .gitignore file lists patterns of files Git should not track. For a Maven project you should ignore:

Code
# Build outputtarget/# IDE and editor files.idea/*.iml.vscode/.classpath.project.settings/# Local configuration and secrets.envapplication-local.properties*.pem*.key

Here Git itself confirms that the patterns work. The target/ and .env files exist on disk, and git check-ignore -v shows which rule matches each:

Code
$ git check-ignore -v target/app.class .env.gitignore:3:target/	target/app.class.gitignore:4:.env	.env

Details worth knowing, from the Git documentation:

  • A pattern ending in / (such as target/) matches only directories.
  • .gitignore only affects files that are not yet tracked. If you already committed a file, ignoring it later does nothing. Stop tracking it with git rm --cached <file> (this removes it from Git but leaves it on disk), commit, and then ignore it.
  • Commit the .gitignore itself, so the whole team shares it.
  • Do not ignore things the project needs to build: pom.xml, the source folders, and the Maven Wrapper files (mvnw, .mvn/).

Never commit secrets

#

Passwords, API keys, tokens, private keys and database URLs with credentials do not belong in Git, even in a private repository. Repositories get cloned, forked, shared with contractors and made public by mistake, and history is permanent.

  • Keep secrets in environment variables or a secret manager, and read them in the application. Article 38 shows how Spring Boot does this.
  • Commit a template with fake values (application-example.properties) so others know what to set.
  • Ignore the real local files, as above.

If a secret is committed, treat it as compromised. GitHub's documentation gives the order of response: the first step is to revoke or rotate the secret, so it can no longer be used; rewriting history to remove it may then not even be necessary, although for other sensitive data you may still choose to. Deleting the file in a new commit does not remove the secret from history, and it may already have been copied.

Undoing things safely

#

Situation

Command

Notes

Discard unstaged edits to a file

git restore <file>

Destroys those edits permanently

Unstage a file

git restore --staged <file>

Keeps the edits

Fix the last commit message (not yet pushed)

git commit --amend

Rewrites that commit

Undo a commit that is already shared

git revert <hash>

Adds a new commit that reverses it; safe for shared history

Temporarily set work aside

git stash, then git stash pop

Useful before switching branch

See who changed a line, and why

git blame <file>, git log -p <file>

Read the message for the reason

Find which commit broke something

git bisect

Binary search through history

The real git restore run for this article: after appending a stray line to Greeting.java, git diff --stat showed 1 file changed, 1 insertion(+), and after git restore the working tree was clean again. Be careful with commands that discard work (git restore, git reset --hard, git clean, git push --force): they cannot always be undone, so read the command twice and check git status first. Never force-push to a shared branch such as main.

A sensible daily routine for your own projects

#
  1. git status, git pull.
  2. Branch for the task.
  3. Work in small steps, commit at each working step with a clear message.
  4. Run mvn clean verify before pushing.
  5. Push, open a pull request (even on solo projects it builds the habit), merge, delete the branch.

Common mistakes

#
  • Committing target/, IDE files or local configuration.
  • Committing secrets, and thinking that a deleting commit fixes it.
  • Using git add . blindly and committing unrelated files.
  • Huge commits and vague messages ("fix", "stuff").
  • Working directly on main with no review.
  • Resolving a conflict without understanding it, or leaving marker lines (<<<<<<<) in the file. The build usually fails, but not always when they are in comments or text.
  • Force-pushing shared branches.
  • Never pulling, then facing a giant conflict.
  • Treating Git as a backup system with no remote and no pushes.

Exercises

#

Exercise 1

Staging

#

You edited Greeting.java and README.md, and you want two separate commits. Which commands do you use?

Exercise 2: Read the markers

#

In a conflicted file, what is between <<<<<<< HEAD and =======? What must you do before committing?

Exercise 3: Fix a tracked file

#

You committed application-local.properties, then added it to .gitignore, and git status still shows changes to it. Explain and fix, and say what extra step you need if it contained a real password.

Exercise 4: Revert or reset

#

You pushed a commit to a shared main that broke the build. Which command do you use to undo it safely, and why not rewrite history?

Exercise 5: Write the message

#

Rewrite "fixed bug" as a good commit message for a change that stops greet(" ") from returning "Hello, !".

Show solutionHide solution
Shell
git add src/main/java/app/Greeting.javagit commit -m "Greet the user by name"git add README.mdgit commit -m "Document how to run the greeter"

Staging only the files for each logical change lets you split the work into two commits. Check git status between them.

Exercise 2

Show solutionHide solution

Between <<<<<<< HEAD and ======= is the version from your current branch. The incoming branch's version is between ======= and >>>>>>>. Before committing, edit the file to the final correct text and delete all three marker lines, then git add the file, build and test, and commit.

Exercise 3

Show solutionHide solution

.gitignore only affects untracked files, and the file is already tracked. Run git rm --cached application-local.properties, commit that, and keep the ignore rule. If it contained a real password, that password is in history, so revoke or rotate it immediately; removing the file from the repository does not make the old password safe.

Exercise 4

Show solutionHide solution

Use git revert <hash>, which adds a new commit undoing the change. Others have probably already pulled the bad commit, so rewriting the shared history (reset and force push) would make their copies diverge and cause confusing conflicts or lost work. A revert keeps the history truthful and is safe for shared branches.

Exercise 5

Show solutionHide solution

For example: Reject blank names in Greeter.greet as the summary, with a body such as: Previously greet(" ") returned "Hello, !". Now it throws IllegalArgumentException, and a test covers it. The summary uses the imperative mood and says what changed, and the body says why.

Where to go next

#

Sources & further reading

  1. git-scm.com
  2. git-scm.com
  3. docs.github.com
  • Guide · Technology

    Git for Beginners: The Handful of Commands You Actually Need

  • Guide · Technology

    Heaps and Priority Queues

  • Course · Technology

    DSA From Zero to Job-Ready: A Practical Guide with Java