Git Workflow for a React Project
Understand the core Git concepts — repositories, commits, branches, merges, and rebases — and apply them to a real React project workflow: pull requests, conflict resolution, .gitignore, handling environment files and lock files, and writing conventional commits.
Git is the version-control system underneath essentially every professional software project, React apps included. It's not just a way to 'save your code' — it is how a team works on the same codebase in parallel, reviews each other's changes before they land, and can trace exactly when and why any given line changed. This lesson treats Git as a working tool for a React repository specifically: what belongs in .gitignore, how a feature branch becomes a merged pull request, and what to do when two people touch the same file.
Repository, commit, and branch
A repository (repo) is a directory tracked by Git, containing the full history of every change ever made to it, stored in a hidden .git folder. A commit is a snapshot of the entire tracked project at a point in time, with a message describing the change, an author, a timestamp, and a pointer back to its parent commit(s) — this chain of commits is the project's history. Commits are cheap and should be small and focused: one commit per logical change makes history readable and makes git bisect (binary-searching history to find which commit introduced a bug) actually useful.
A branch is simply a movable pointer to a commit. The default branch (commonly main) represents the project's mainline. Creating a new branch (git switch -c feature/login-form) lets you commit changes in isolation without touching main, so multiple people (or multiple features) can be worked on in parallel without stepping on each other until they're explicitly combined.
• git init — create a new repository • git add <file> — stage changes for the next commit • git commit -m "message" — record a snapshot of staged changes • git switch -c <branch> (or git checkout -b <branch>) — create and switch to a new branch • git log --oneline — view commit history compactly
Merge and rebase
A merge combines the history of two branches by creating a new 'merge commit' with two parents — it preserves exactly what happened, including the fact that work diverged and was later brought back together. git merge feature/login-form (while on main) brings the feature branch's commits into main.
A rebase instead replays one branch's commits on top of another branch's latest state, producing a linear history with no merge commit — it rewrites the commits' parent pointers (and therefore their hashes) as if the feature branch had been started from the newer commit all along. Rebasing keeps history clean and linear but must never be done on commits that have already been pushed and that other people may have based work on — rewriting shared history breaks everyone else's copy of it. A common safe pattern: rebase your own feature branch on the latest main before opening a pull request (to resolve conflicts early and keep history tidy), but never rebase main itself or a branch others are collaborating on.
• Merge: preserves full history, creates a merge commit, safe on shared branches • Rebase: rewrites commit history to be linear, must only be done on local/unshared commits • git pull --rebase rebases your local unpushed commits on top of the latest remote instead of merging • Never rebase a branch that others have already pulled or built work on top of
Pull requests and conflict resolution
A pull request (PR) is not a Git concept itself — it's a workflow feature provided by hosting platforms (GitHub, GitLab, etc.) that proposes merging one branch into another, gives reviewers a diff to comment on, and typically gates the merge behind CI checks and approvals. This is the mechanism by which the lint, format, and test checks from earlier lessons become a hard requirement rather than a suggestion.
A merge conflict happens when Git cannot automatically reconcile changes — most often because two branches modified the same lines of the same file differently. Git marks the conflicting regions directly in the file with <<<<<<<, =======, and >>>>>>> markers; resolving a conflict means editing the file to the version you actually want (which may be one side, the other, or a hand-merged combination), removing the markers, then git add-ing the file and completing the merge or rebase. In a React project, conflicts are especially common in package.json and lock files when two branches both add dependencies — resolve package.json by hand, then regenerate the lock file with a fresh install rather than trying to hand-merge it.
• PRs add review + CI gating on top of Git's raw merge/rebase mechanics • Conflict markers: <<<<<<< (yours), ======= (divider), >>>>>>> (theirs) • For package.json conflicts: resolve by hand, then regenerate the lock file rather than merging it manually • After resolving, stage the file and continue: git add <file> then git rebase --continue or complete the merge commit
.gitignore, environment files, and lock files
Not everything in a project directory belongs in version control. A .gitignore file lists patterns Git should never track: build output (dist/, build/), dependency folders (node_modules/), editor and OS artifacts (.DS_Store, .vscode/* except shared config), and — critically — local secrets. Environment files like .env or .env.local often hold API keys, database URLs, or other secrets that must never be committed; they belong in .gitignore, with a committed .env.example documenting which variables are required without their real values.
Lock files are the opposite case: despite living inside node_modules-adjacent tooling, package-lock.json (or yarn.lock/pnpm-lock.yaml) must be committed, not ignored, because it is what makes installs reproducible across the team and in CI, as covered in the dependency management lesson.
• Ignore: node_modules/, dist/ or build/, .env and .env.local, coverage/, *.log • Never ignore: package-lock.json / yarn.lock / pnpm-lock.yaml, source code, package.json • Commit a .env.example with placeholder values so teammates know what variables they need • If a secret is accidentally committed, rotating the credential is required — removing it from history alone is not enough since it may already be cached or forked
Conventional commits
Conventional Commits is a lightweight convention for commit messages: <type>(<optional scope>): <description>, where type is one of a small fixed vocabulary — feat (a new feature), fix (a bug fix), docs, refactor, test, chore, ci, among others. A breaking change is marked with a ! after the type/scope or a BREAKING CHANGE: footer. This isn't just a style preference: tools like semantic-release parse conventional commit messages to automatically determine the next semantic version number and generate a changelog, and a consistent prefix makes git log itself far easier to scan.
Being aware of the convention is valuable even on a team that doesn't strictly enforce it, because it forces you to answer 'what kind of change is this, really?' before writing the message, which tends to produce smaller, more focused commits.
• feat: add user profile page • fix: correct off-by-one error in pagination • chore: bump eslint to 8.57.0 • feat!: remove deprecated useLegacyAuth hook (breaking change)
A typical feature-branch workflow
Rebasing your own feature branch onto the latest main before opening a PR surfaces conflicts early, while they're small. git branch -d only deletes a branch that has been fully merged; use -D to force-delete an unmerged branch (rarely what you want).
# start a new feature from the latest main
git switch main
git pull
git switch -c feat/user-avatar
# work, committing in small logical steps
git add src/components/Avatar.tsx
git commit -m "feat(avatar): add Avatar component with fallback initials"
# keep the branch current with main before opening a PR
git fetch origin
git rebase origin/main
# resolve any conflicts, then:
git rebase --continue
# push and open a pull request
git push -u origin feat/user-avatar
# after review approval + passing CI, merge via the hosting platform
# then clean up locally
git switch main
git pull
git branch -d feat/user-avatarCode Examples
node_modules and build output are regenerated from source and the lock file, so tracking them would bloat the repo for no benefit. .env files hold secrets and must never be committed. The .vscode exceptions allow committing the two shared config files while still ignoring personal editor state.
Common mistake: Forgetting to ignore .env and accidentally committing an API key — this requires rotating the leaked credential immediately, not just deleting the file in a later commit.
HEAD is your current branch's version; the section after ======= is the incoming branch's version. Here both changes are wanted — the resolution keeps the sticky class and the SearchBar — so you'd edit the file down to a single combined version and remove all three marker lines. Expected result: function Header() { return ( <header className="site-header sticky"> <Logo /> <SearchBar /> </header> ); }
# dependencies
node_modules/
# build output
dist/
build/
# environment secrets
.env
.env.local
.env.*.local
# logs and coverage
*.log
coverage/
# editor/OS
.DS_Store
.vscode/*
!.vscode/settings.json
!.vscode/extensions.jsonfunction Header() {
return (
<<<<<<< HEAD
<header className="site-header">
<Logo />
</header>
=======
<header className="site-header sticky">
<Logo />
<SearchBar />
</header>
>>>>>>> feat/sticky-header
);
}Feature branch to merged main
The feature branch is rebased onto the latest main to resolve conflicts early, then merged via a pull request once CI and review pass.
main: A ─── B ─────────────── E (merge commit)
\ /
feature: C ─── D ───────
(rebased onto B before PR, then merged)Important Rules
Commit small, logical, focused changes — not 'end of day' dumps of unrelated work.
Never rebase commits that have already been pushed and that others may have built on.
Always .gitignore environment files and node_modules; always commit lock files and source.
Resolve package.json conflicts by hand, then regenerate (don't hand-merge) the lock file.
If a secret is ever committed, rotate the credential — do not assume deleting it from history is sufficient.
Why this matters in production
Pull-request-gated Git workflows are the mechanism that makes code review, CI checks, and deployment approvals actually enforceable — without them, anyone could push straight to main and break production. A disciplined .gitignore and conventional-commit habit is also what makes automated tooling (changelogs, semantic-release, Dependabot) work reliably instead of silently failing on malformed history.
Committing .env directly because 'it's just for local dev'.
Add .env to .gitignore and commit a .env.example with placeholder values instead.
Once a secret is pushed, it may be cached by the hosting platform, forked, or cloned before it's noticed — the only safe response is treating the credential as compromised and rotating it.
Force-pushing a rebase of main because 'the history looked messy'.
Only rebase your own local, unpushed, or unshared feature branches — never a shared branch like main.
Rewriting a shared branch's history invalidates every other clone's history, forcing everyone else to manually reconcile or lose work.
What is the key difference between a merge and a rebase? A. A merge deletes the original branch; a rebase keeps it B. A merge creates a merge commit and preserves full history; a rebase replays commits to produce linear history, rewriting commit hashes C. A rebase can only be done by repository administrators D. They are two different names for the exact same operation
B. A merge creates a merge commit and preserves full history; a rebase replays commits to produce linear history, rewriting commit hashes — Merge preserves the true, branching history via a merge commit; rebase rewrites history to look linear, which is why it must never be done on commits others have already pulled.
Which of these should always be committed to a Git repository for a React project? A. .env with real API keys B. node_modules/ C. package-lock.json D. dist/ build output
C. package-lock.json — The lock file must be committed for reproducible installs. .env (secrets), node_modules (regenerable), and build output (regenerable) all belong in .gitignore.
If an API key is accidentally committed and pushed, what is the correct response? A. Delete the file in a new commit; the key is no longer a concern B. Rotate/revoke the leaked credential immediately, since it may already be cached, cloned, or forked C. Rename the file so it's harder to find D. Nothing needs to be done as long as the repository is private
B. Rotate/revoke the leaked credential immediately, since it may already be cached, cloned, or forked — Once pushed, a secret should be treated as compromised regardless of later history edits, because copies of the history may already exist elsewhere.
What does the Conventional Commits prefix feat!: signal? A. A new feature that is also a breaking change B. A feature that failed CI C. A commit that only touches formatting D. A commit made by an automated bot
A. A new feature that is also a breaking change — The ! after the type marks a breaking change, which tools like semantic-release use to determine that a major version bump is required.
Create a feature branch, make two small conventional commits, intentionally create a merge conflict with main by editing the same line on both branches, then resolve it and open the branch as if it were a pull request. Hint: Edit the same line of the same file on both main and your feature branch, then run git rebase main from the feature branch to trigger the conflict.
Try it yourself — there's no single right answer here; compare your result against this lesson's examples.