NovuSpark
All articles
CI/CD & DevOpsSeptember 12, 2025 · NovuSpark Team

Git Fundamentals Every Team Should Standardize On

This is the first post in our GitHub and GitHub Actions series. Later posts cover Actions basics, reusable workflows, securing Actions, and multi-environment deployments.

Almost every confusing Git moment a team hits isn't actually a Git problem — it's a convention nobody agreed on. Two people using different commit message styles, different branch naming, or different rules about when to rebase versus merge will eventually produce a history that's technically correct and practically unreadable. This post covers the handful of conventions worth deciding deliberately, before a team is large enough for the lack of one to actually hurt.

Commits: atomic, and explaining why

A commit should represent one coherent change — not "everything I did today," and not a single line fix split across three commits for no reason.

git add src/auth/login.ts
git commit -m "Fix session token expiry check off-by-one error
 
The comparison used > instead of >=, causing tokens to be treated
as valid for one extra second past their actual expiry. Low
severity, but worth backporting to the 2.3 release branch."

The first line is a short summary (conventionally under ~50 characters, imperative mood — "Fix," not "Fixed" or "Fixes"); the blank line and body explain why, not just what — a diff already shows what changed; a message that just restates the diff in prose adds nothing a reviewer, or you in six months, couldn't already see.

Branch naming: a convention, chosen once, followed consistently

git checkout -b feature/user-profile-avatar
git checkout -b fix/session-expiry-off-by-one
git checkout -b chore/upgrade-eslint-config

The exact prefixes matter far less than having one convention and using it consistentlyfeature/, fix/, chore/ is a common, reasonable choice, but a team that picks something different and applies it uniformly is in a genuinely better position than a team using three different, undocumented styles simultaneously. The actual payoff shows up in tooling: CI can trigger different behavior based on branch prefix, and anyone scanning a branch list instantly understands each one's purpose.

Merge vs. rebase: understanding the trade-off, not picking a side dogmatically

# Merge: preserves exact history, including a merge commit
git checkout main
git merge feature/user-profile-avatar
 
# Rebase: replays your commits on top of the latest main, linear history
git checkout feature/user-profile-avatar
git rebase main
mergeboth lines of work stay visiblerebasefeature commits replayed, hashes rewritten
Fig. 1 — merge is an honest record of two diverging branches; rebase produces a clean line by rewriting commit hashes

merge preserves exactly what happened, including a merge commit showing where two lines of work joined — an honest record, at the cost of a history that can look tangled with enough concurrent branches. rebase rewrites your branch's commits to appear as if they were made on top of the latest main, producing a clean, linear history — at the cost of literally rewriting commit hashes, which is fine on a branch only you're using, and actively dangerous on a branch anyone else has already pulled.

The one rule worth actually enforcing: never rebase a branch that other people have already pulled and might be working from — doing so rewrites history they've already based work on, and reconciling the two diverging versions afterward is a genuinely painful, error-prone process. Rebasing your own feature branch before opening a pull request is safe and common; rebasing main itself, or a shared long-lived branch, is not.

Interactive rebase: cleaning up a branch's own history before it's shared

Beyond replaying commits onto main, rebase -i (interactive) is worth knowing specifically for cleaning up a feature branch's own commit history before opening a pull request — squashing a string of "fix typo," "actually fix it," "wip" commits into one coherent commit that explains a real, complete change:

git rebase -i HEAD~4
pick a1b2c3d Add avatar upload endpoint
squash e4f5g6h wip
squash h7i8j9k actually fix upload validation
squash k1l2m3n fix typo

Changing pick to squash on the last three lines combines all four commits into one, with a chance to write a single, coherent commit message for the combined change. This is a genuinely different situation from rebasing a shared branch onto main: cleaning up your own not-yet-shared branch's history before anyone else has pulled it is safe, and often actively improves the eventual pull request's reviewability.

Pull requests: small, and reviewable

A pull request touching 40 files across unrelated concerns gets a shallow review, because no reviewer can hold that much context at once and give each part real attention. A pull request making one focused, explainable change gets an actual review — a reviewer can reasonably understand the whole thing, and disagreements surface on the specific change being made rather than getting lost across an enormous diff.

gh pr create --title "Fix session token expiry off-by-one" \
  --body "Comparison used > instead of >=. Adds a regression test."

The size discipline this requires is a genuine skill, not just a preference — it usually means resisting the urge to also fix an unrelated thing you noticed while you were in that file, and opening a separate, equally small PR for it instead.

Recovering from a mistake: reflog as the safety net

Rewriting history (rebasing, amending, resetting) occasionally goes wrong — a rebase that loses a commit, a reset --hard run against the wrong branch. git reflog is worth knowing before that happens, not after, because it records every place HEAD has pointed, including commits a rewritten history no longer references directly:

git reflog
a1b2c3d HEAD@{0}: rebase finished: returning to refs/heads/feature/avatar
e4f5g6h HEAD@{1}: rebase: Add avatar upload endpoint
9f8e7d6 HEAD@{2}: checkout: moving from main to feature/avatar
git reset --hard HEAD@{2}

Even a commit that looks "lost" after a bad rebase or reset is usually still recoverable via reflog, for a genuinely useful window of time (Git's default garbage collection eventually cleans up truly unreferenced commits, but not immediately) — worth trying before assuming work is actually gone.

.gitignore: getting it right from the very first commit

# .gitignore
node_modules/
.env
.env.local
*.log
.DS_Store
dist/

Getting this wrong the first time is the recurring, real-world mistake we've referenced multiple times already in this blog — committing node_modules, a .env file with real secrets, or OS-specific junk files like .DS_Store into a repository's permanent history. A blanket ignore rule can also be too aggressive: we hit this ourselves building this exact site, where a blanket .env* rule accidentally excluded .env.local.example — a harmless template file that was actually meant to be committed and shared. Review what a .gitignore pattern actually excludes, on a real git status, rather than assuming a broad pattern does only what you intended.

Signed commits: verifying who actually authored a change

A commit's author field is trivially spoofable — git config user.email accepts any value, with no verification at all. GPG-signed commits close that gap, at least for repositories where authorship provenance genuinely matters:

git config --global user.signingkey <your-gpg-key-id>
git config --global commit.gpgsign true
git commit -m "Fix session token expiry off-by-one error"
git log --show-signature -1
gpg: Good signature from "Jane Doe <jane@novuspark.com>"

Requiring signed commits on a protected branch (configurable in GitHub's branch protection settings) means every commit reaching that branch is cryptographically verifiable as having come from the claimed author's actual key, not just a git config value anyone could set to any name. Worth adopting specifically for repositories where a real compliance or security requirement exists around code provenance — genuinely more setup overhead than most teams need for everyday work.

Protected branches: enforcing review before merge

Beyond convention, GitHub's branch protection rules turn "we review pull requests before merging" from a team norm into an actually-enforced rule:

Settings → Branches → Branch protection rules
  ✓ Require a pull request before merging
  ✓ Require approvals: 1
  ✓ Require status checks to pass (CI)
  ✓ Do not allow bypassing the above settings

This is the same "enforce it in the system, not just as a convention people are trusted to follow" principle covered for least-privilege IAM policies and GitHub Actions permissions elsewhere in this blog — a review requirement documented in a team wiki can be forgotten under deadline pressure; a branch protection rule genuinely blocks the merge button until its conditions are met, regardless of pressure or intent.

What to actually remember from this post

  • Atomic commits with a message explaining why are worth more, to future readers, than a diff alone ever communicates on its own.
  • Pick one branch-naming convention and use it consistently — the specific choice matters less than the consistency.
  • Never rebase a branch other people have already pulled — that's the one rule worth genuinely enforcing, not a general "rebase is bad" stance. Interactive rebase on your own, not-yet-shared branch is a different, safe case.
  • git reflog is the safety net after a bad rebase or reset — a commit that looks lost is usually still recoverable for a real window of time.
  • Small, focused pull requests get real reviews; large ones get rubber-stamped, because no reviewer can meaningfully hold that much context at once.

Next in the series: GitHub Actions 101: Your First CI Workflow, where these conventions become the foundation for actually automating a build and test pipeline.

Ready when you are

Want training built around your team's real work?

Tell us about your team and what you're trying to solve — we'll recommend a program that fits.