A team needs a workflow simple enough that everyone follows it under pressure. The version below keeps one long-lived branch (main), does all changes through short-lived branches and pull requests, and lets CI decide whether a change may merge. It works for application code, infrastructure code and documentation alike.
Versions in use on this date. Git 2.36 (released 18 April 2022) and GitHub. Note a security change in the 2.35.2 line (April 2022): Git now refuses to work in a repository owned by a different user unless it is listed in safe.directory, which you will meet in CI containers and shared servers (git config --global --add safe.directory /path/to/repo).
The flow
flowchart TB
M[main - always deployable] --> B[git switch -c feature/login-timeout]
B --> C[Small commits]
C --> P[git push -u origin and open pull request]
P --> CI{CI checks: tests, lint, scan}
CI -->|fail| C
CI -->|pass| R[Review by CODEOWNERS]
R -->|changes requested| C
R -->|approved| MG[Squash or rebase merge]
MG --> M
M --> D[Deploy from main or tag]
M -->|hotfix: branch from main, same flow| B
Principles: main is always releasable; branches live for hours or a few days, not weeks; one logical change per pull request; and nobody pushes straight to main.
Daily commands
git switch main && git pull --ff-only
git switch -c fix/payment-timeout # `git switch` and `git restore` are the clearer modern commands
# edit, test
git status; git diff
git add -p # stage hunks deliberately
git commit -m "Fix payment timeout by retrying idempotent calls"
git fetch origin && git rebase origin/main # stay current; resolve conflicts here, not in the PR
git push -u origin fix/payment-timeout
gh pr create --fill # GitHub CLI, or use the web UI
After merge: git switch main && git pull --ff-only && git branch -d fix/payment-timeout, and delete the remote branch (GitHub can do it automatically).
Good commits and pull requests
- Commit messages: a short imperative subject (about 50 characters), a blank line, then why the change was needed. Optionally follow Conventional Commits (
fix:,feat:,chore:) if you want automatic changelogs and versioning. - Small pull requests (under about 400 changed lines) get faster, better reviews and break less.
- Describe what changed, how to test it, and the risk or rollback plan; link the ticket.
- Squash merge for a clean history with one commit per change, or rebase merge to keep individual commits; avoid merge commits from long-lived branches unless the team prefers them.
- Never commit secrets, large binaries or generated files. Add a
.gitignoreearly, and a secret scanner such asgitleaksin a pre-commit hook and CI.
Protect main on GitHub
In Settings, Branches, Branch protection rules for main:
-
Require a pull request with at least 1 approval (2 for critical repos), and dismiss stale approvals on new commits.
-
Require status checks (tests, lint, security scan) to pass and the branch to be up to date.
-
Require review from code owners using a
CODEOWNERSfile:# .github/CODEOWNERS /infra/ @acme/platform-team /services/payments/ @acme/payments-team *.tf @acme/platform-team -
Block force pushes and deletions; require signed commits if your policy needs them; include administrators in the rules.
Releases and hotfixes
Tag releases from main: git tag -a v1.8.0 -m "Release 1.8.0" && git push origin v1.8.0 (semantic versioning). If you must support old versions, create a release/1.7 branch from the tag and cherry-pick fixes (git cherry-pick -x <sha>). Hotfix process is the same as normal changes, just faster, and never skips review and CI.
Alternatives, briefly
| Model | Fits | Cost |
|---|---|---|
| Trunk-based (above) | Continuous delivery, small teams, strong CI | Needs feature flags for unfinished work |
GitFlow (develop, release, hotfix) |
Versioned products with scheduled releases | Many long-lived branches, painful merges |
| Fork and pull request | Open source, external contributors | More setup per contributor |
Recover from common mistakes
git restore --staged file.txt # unstage
git restore file.txt # discard working changes (irreversible)
git commit --amend # fix the last commit (only if not pushed or only you use the branch)
git revert <sha> # undo a pushed commit with a new commit
git reflog # every place HEAD has been: your safety net
git switch -c rescue <sha-from-reflog> # recover "lost" commits
git push --force-with-lease # after rebasing your own branch; never plain --force on shared branches
git stash push -m "wip"; git stash pop
Rules for rewriting history: rebase and amend only commits that exist only on your branch. Once something is merged to main, use revert. If a secret was pushed, rotating the secret matters more than rewriting history, because clones and caches may already have it.