Skip to content
My Guidelines
Guides
In this guide (14 items)

Git Workflow & Best Practices

Git Workflow & Best Practices

House standard for using Git: commit hygiene, conventional commits, branching, pull requests, history discipline, and secret safety. These rules are what github-actions.md enforces in CI (commit-lint, semver, branch/tag rulesets) — keep both in sync.

1. Commit philosophy

  • Atomic commits. One logical change per commit. A commit should build and pass tests on its own. Don’t mix a refactor with a feature, or formatting with logic.
  • Self-explanatory. The message says why, the diff says what. Future you reads the log, not your memory.
  • Commit early, commit often — locally. Squash into clean units before the branch is reviewed/merged.
  • Never commit secrets, generated artifacts, or large binaries. (§8, §9)

2. Conventional commits

Every commit follows Conventional Commits. This is mechanically enforced by commitlint in CI and drives automatic semantic versioning.

<type>(<scope>): <subject>
<body>
<footer>

Allowed types (lower-case, never empty):

TypeUseSemVer
featnew user-facing capabilityminor
fixbug fixpatch
refactorbehavior-preserving restructuring–
perfperformance improvementpatch
testadd/adjust tests only–
docsdocumentation only–
styleformatting, no logic (whitespace, lint)–
buildbuild system / dependencies–
ciCI config / pipelines–
choretooling, housekeeping–
revertreverts a previous commit–

Rules (matching the enforced commitlint ruleset):

  • type required, lower-case, from the list above.
  • scope optional, lower-case (e.g. feat(auth):).
  • subject required, imperative mood (“add”, not “added”/“adds”), no trailing period, ≤ 100 chars; full header ≤ 120 chars.
  • Body wraps at ~100 cols, explains the why and trade-offs.
  • Breaking changes: add ! after type/scope and a BREAKING CHANGE: footer → triggers a major bump.
feat(payment)!: replace synchronous capture with async authorization
Capture now happens via the settlement worker, not inline on the request.
BREAKING CHANGE: POST /payments returns 202 + a status URL instead of 200.
Co-Authored-By: ...

Good vs bad:

✅ fix(auth): reject tokens whose exp equals now (use <=, not <)
✅ refactor(order): extract pricing into PricingService
❌ Fixed stuff # no type, vague, past tense
❌ feat: Added Feature. # capitalized subject, past tense, trailing period
❌ wip # not a change description

3. Branching model

Trunk-oriented flow with two long-lived protected branches and short-lived work branches.

  • main — production; always releasable; tagged on release.
  • develop — integration; what the next release is cut from.
  • Work branches — short-lived, branched from develop, one concern each:
PrefixFor
feature/<slug>new capability
fix/<slug>bug fix
refactor/<slug>restructuring
chore/<slug>tooling/maintenance
hotfix/<slug>urgent prod fix, branched from main

Rules

  • Branch names are validated in CI (validate-source-branch) — use the prefixes.
  • Keep branches short-lived (hours/days, not weeks); rebase on develop often.
  • One branch = one PR = one concern. Split large work into stacked PRs.
  • Delete the branch after merge (CI delete-branch automates this).

4. Keeping a branch current

Prefer rebase for local/feature branches to keep history linear; never rebase shared/protected branches.

Terminal window
git fetch origin
git rebase origin/develop # replay your commits on top of latest develop
# resolve conflicts → git add -p → git rebase --continue
git push --force-with-lease # safe force-push; refuses if remote moved
  • Use --force-with-lease, never bare --force (it clobbers others’ pushes).
  • Don’t rebase a branch someone else is also committing to — merge instead.
  • Resolve conflicts per-hunk; re-run tests after a rebase.

5. Pull requests

PRs are the only path into main/develop (enforced by ruleset).

  • Small and focused. A reviewable PR is < ~400 changed lines. Stack PRs for big work.
  • Description: what + why + how to test; link the issue/ADR. If it’s an architecturally significant decision, attach an ADR.
  • Green before review: PR pipeline (lint, build, test, security) must pass.
  • Required: 2 approvals, all review threads resolved, approval after the last push, status checks green on the latest commit.
  • Merge method depends on the target branch — see §5.1. The squash commit subject must itself be conventional (it’s what semver reads).
  • No force-push to the base.

5.1 Squash or merge

The rule follows the branch the PR lands on, not the size of the change. Enforce it per branch with the ruleset’s allowed_merge_methods (github-actions.md §8) so nobody has to remember it.

PRMethodWhy
work branch → develop (feature/*, fix/*, dependabot…)SquashOne commit per change, with the PR title as subject: clean changelog, and one git revert undoes the whole change. Intermediate commits (“wip”, “fix typo”) disappear.
develop → main (release PR)Merge commitKeeps develop an ancestor of main (see below).
hotfix/* → mainMerge commit, then merge main back into developSame reason: the fix must reach develop with its SHA, not as a copy.
Trunk-based repo (no develop): work branch → mainSquashmain is the integration branch; there is no second branch to diverge from.

Why the release PR must not be squashed. A squash creates a new commit on main with the same content but a different SHA. Then:

  • main and develop diverge. develop never contains the release commit, so the next release PR lists already-released commits as pending again, and conflicts accumulate.
  • The tag stops being traceable. vX.Y.Z points at a commit whose ancestry does not include the PRs it shipped, so “which version first had PR #76” stops having an answer in git.
  • Version calculation goes blind. The release job reads git log <last-tag>..HEAD for feat / BREAKING CHANGE. With a squash, it reads one commit whose message depends on the repo’s squash-message setting, and a BREAKING CHANGE footer from one of the commits can be lost, shipping a major change as a minor.

Rebase merge is not used: it rewrites SHAs like a squash does, but keeps the noise of the intermediate commits.

Cost of squashing into develop: stacked branches lose their base. A branch cut from another work branch still contains the original commits, which no longer exist on develop. Replay only your own commits onto the squash:

Terminal window
git fetch origin
git rebase --onto origin/develop <last-sha-of-the-base-branch> <your-branch>
git push --force-with-lease

Before merging a PR whose branch was force-pushed, check that the PR points at the new head: gh pr view <n> --json headRefOid must equal git rev-parse HEAD.

6. Contributing to a repository (fork-based)

For repos where you lack push access (OSS, cross-team, third-party), contribute via the fork + pull-request model. §5 covers PRs from a branch you can push; this covers PRs from a fork you can’t.

Before you code

  • Read the repo’s CONTRIBUTING.md and Code of Conduct first — the project’s own process always wins over this guide. Generate both for your own projects from the templates in resources.md.
  • Discuss first. Open (or claim) an issue describing the change before writing code; get maintainer buy-in on anything non-trivial. An unsolicited large PR with no prior discussion usually gets closed.

Fork & keep it synced — add the canonical repo as upstream; rebase on it before branching and before every push so your PR stays conflict-free.

Terminal window
# fork on the host, then clone your fork
git clone git@github.com:<you>/<repo>.git && cd <repo>
git remote add upstream git@github.com:<ORG>/<repo>.git # canonical repo
git fetch upstream
git switch develop # or main, per the repo's base branch
git rebase upstream/develop # never commit directly on your fork's base
git push # fast-forward your fork's base

Propose the change — branch off upstream, push to your fork (origin), open the PR against the canonical repo.

Terminal window
git switch -c feature/<slug> upstream/develop
# ... atomic conventional commits (§1, §2) ...
git push -u origin feature/<slug> # origin = your fork
# open PR: base = <ORG>:develop, head = <you>:feature/<slug>, link the issue
  • Same bar as §5: focused diff, green pipeline, conventional squash subject; PR body says what + why + how to test and links the issue (Closes #123).
  • DCO / CLA: if the project requires a Developer Certificate of Origin, sign off every commit — git commit -s adds a Signed-off-by: trailer (§9). Some orgs require a signed CLA instead; check CONTRIBUTING.md.
  • Respond to review promptly; push fixups to the same branch (the PR updates itself) — don’t open a new PR per revision.
  • Follow the Code of Conduct in every interaction; assume good faith, credit sources, never paste proprietary or unlicensed code.

7. History discipline

Treat shared history as immutable — the same principle as liquibase.md for migrations.

  • Never rewrite published history on main/develop (no force-push, no amend of pushed commits). Fix forward with a revert or a new commit.
  • Local, unpushed history is yours to clean up: git rebase -i, squash, reorder, reword — before opening/updating the PR for review.
  • Revert a bad commit on a protected branch with git revert <sha> (creates an inverse commit; preserves history).
  • Tags are immutable: cut vX.Y.Z, never move or delete a released tag (tag ruleset blocks update/delete/force).

8. Secrets & sensitive data

  • Never commit API keys, tokens, passwords, connection strings, private keys, .env files. CI runs secret scanning (TruffleHog) and will fail the build.
  • Pre-commit grep before pushing: look for sk-, ghp_, AKIA, password=, secret=, BEGIN PRIVATE KEY.
  • If a secret is committed: rotate it immediately (assume it’s compromised), then purge from history (git filter-repo) and force-push with coordination — rotation first, scrubbing second.
  • Keep secrets in the platform’s secret store / CI secrets, referenced by env var (see security.md).

9. .gitignore & repo hygiene

  • Commit a .gitignore covering build output (build/, target/, dist/, node_modules/), IDE files (.idea/, .vscode/), OS cruft (.DS_Store), and local env (.env, *.local).
  • Keep gradle/wrapper/gradle-wrapper.jar and lockfiles (*.lock, pnpm-lock.yaml) — they’re reproducibility inputs, not artifacts.
  • Large binaries → Git LFS, not the main tree. Better: don’t version build outputs at all; rebuild them in CI.
  • One concern per repo; don’t vendor unrelated tooling into product repos.

10. Signing & identity

  • Configure a real user.name/user.email matching your account.
  • Sign commits (GPG/SSH/Sigstore gitsign) where the org requires verified commits; rulesets can mandate signed commits on protected branches.
  • Bot/CI commits use the Actions identity and are the only actors allowed to bypass tag protection.

11. Hooks & local enforcement

Catch problems before they reach CI.

  • commit-msg hook → run commitlint locally so bad messages fail at commit time, not in the pipeline.
  • pre-commit hook → formatter + linter (e.g. Spotless, ktlint, biome) and a secret-scan; keep it fast.
  • Manage hooks with a lightweight, zero-runtime-dep tool (e.g. simple-git-hooks / lefthook); commit the config so the whole team shares it.
  • Hooks are a convenience, not the source of truth — CI re-runs every check; never rely on local hooks alone.

12. Anti-patterns

❌✅
git commit -m "fix" / “wip” / “stuff”conventional, descriptive subject
Mixing feature + refactor + formatting in one commitatomic commits, one concern
git push --force to a shared branch--force-with-lease, and never on protected branches
Amending/rebasing already-pushed shared historyrevert forward; rewrite only local commits
Long-lived feature branch diverging for weeksshort-lived, rebased on develop often
Committing .env, keys, node_modules, build/.gitignore + secret store + LFS
Merge commits from work branches into developsquash merge (§5.1)
Squash-merging the develop → main release PRmerge commit; squash only into develop (§5.1)
Moving/deleting a released tagtags immutable; cut a new version
Giant 2000-line PRsplit into small/stacked PRs (< ~400 lines)
Relying on local hooks onlyCI re-validates everything
Committing on your fork’s main/develop, or a stale forktrack upstream; rebase before branching (§6)
Opening a fork PR with no prior issue/discussionissue-first; get maintainer buy-in (§6)

13. Quick reference

Terminal window
# start work
git switch develop && git pull --ff-only
git switch -c feature/order-pricing
# commit (conventional)
git add -p
git commit -m "feat(order): add tiered pricing calculator"
# stay current
git fetch origin && git rebase origin/develop
git push --force-with-lease
# clean local history before review
git rebase -i origin/develop # squash/reword unpushed commits
# undo safely on a shared branch
git revert <bad-sha>
# open PR → squash-merge into develop → branch auto-deleted by CI
gh pr merge <n> --squash --delete-branch
# release: develop → main with a merge commit, never squash (§5.1)
gh pr merge <n> --merge
# stacked branch after its base was squash-merged (§5.1)
git rebase --onto origin/develop <last-sha-of-the-base-branch> <your-branch>
git push --force-with-lease
# contribute to a repo you can't push to (fork-based, §6)
git remote add upstream git@github.com:<ORG>/<repo>.git
git fetch upstream && git switch -c feature/<slug> upstream/develop
git push -u origin feature/<slug> # origin = your fork; PR base = <ORG>:develop

Git usage + conventional commits + branching that CI enforces. Pairs with github-actions.md (commit-lint, semver, rulesets), adr.md (record significant decisions in the PR), and liquibase.md (same immutability principle for shared history).