Source Control
Source control is the foundation everything else sits on. Code review, CI/CD, release management, incident response -- they all assume a clean, predictable git workflow. When the branching strategy is unclear, when commit history is unreadable, or when nobody knows whether
mainis deployable, every other practice degrades. Get source control right and the rest of the playbook becomes easier. Get it wrong and you'll spend sprint after sprint untangling merge conflicts, chasing regressions, and debating what "done" means.
Why this matters
S&P ships at the end of each sprint, not continuously. That means the window between "code is merged" and "code reaches production" includes QA, staging validation, and sometimes client sign-off. A source control strategy that works for continuous deployment -- where every commit to main goes straight to production -- does not work here. S&P needs a workflow that keeps main stable, supports parallel feature work, enables sprint-based releases, and handles the occasional hotfix without ceremony.
Source control is also where Integrity becomes visible in the commit log. Every commit message, every branch name, every merge tells a story about how the team works. A clean history is not vanity -- it is an investment in debuggability, onboarding, and traceability. When production breaks at 2 AM, git log and git bisect are your first diagnostic tools. They only work if the history makes sense.
The standard
Branching strategy: Gitflow-lite
S&P uses a simplified Gitflow model adapted for sprint-based shipping. This is not trunk-based development, and that is a deliberate choice. Trunk-based development assumes continuous deployment, comprehensive automated test coverage, and feature flags for incomplete work. Most S&P projects ship on a sprint cadence with a QA phase, which makes a branch-based release flow the better fit.
Core branches (long-lived):
| Branch | Purpose | Protected | Deploys to |
|---|---|---|---|
main | Production-ready code. Every commit on main represents a release that has been through QA and staging. | Yes | Production |
development | Integration branch. All feature branches merge here. This is where the team's current sprint work accumulates. | Yes | Development/QA environment |
staging | Release candidate. When the sprint is ready for final QA and client review, development is merged to staging. | Yes | Staging environment |
Short-lived branches:
| Branch type | Naming convention | Merges into | Lifetime |
|---|---|---|---|
| Feature | feature/PROJ-123-short-description | development | 1-5 days |
| Bugfix | bugfix/PROJ-456-fix-description | development | 1-3 days |
| Hotfix | hotfix/PROJ-789-critical-fix | main and development | Hours |
| Release | release/v1.2.0 | main and development | 1-3 days |
| Chore | chore/PROJ-101-dependency-update | development | 1-2 days |
Why not trunk-based development? Trunk-based development works best when you can deploy any commit. S&P's sprint-based release cycle means code sits in an integration branch for days or weeks before reaching production. During that time, the team needs a stable integration point (development) where QA can test the full sprint scope, and a staging branch where clients can preview what is about to ship. The three-branch model provides this structure.
When to reconsider: If a project has comprehensive automated testing (80%+ meaningful coverage), a fast CI pipeline (under 10 minutes), and the client accepts continuous delivery, trunk-based development with feature flags is a better model. Document this decision as an ADR in the project repo.
Branch naming conventions
Every branch name ties to a Jira ticket. This is not optional. It enables Jira-Bitbucket integration (automatic status transitions, PR links on tickets) and makes it possible to trace any change back to its requirement.
Format: type/PROJ-123-short-kebab-description
# Good -- descriptive, linked to a ticket
feature/VET-42-appointment-booking-api
bugfix/VET-118-fix-timezone-offset-in-calendar
hotfix/VET-200-patch-auth-token-expiry
chore/VET-55-upgrade-nestjs-to-v11
# Bad -- vague, no ticket reference, no type
my-branch
fix-stuff
new-feature
john/wip
Rules:
- Always include the Jira ticket key.
VET-42, not just42or a description. - Use kebab-case for the description. No camelCase, no underscores.
- Keep descriptions short -- 3-5 words that capture the intent. The Jira ticket has the full context.
- The type prefix is mandatory. It tells reviewers and CI what kind of change this is before they open the PR.
Commit message standards
S&P uses Conventional Commits for commit messages. This is not about being pedantic -- it enables automated changelog generation, makes git log scannable, and provides a structured format that AI tools and CI pipelines can parse.
Format:
type(scope): short description
Optional body with additional context.
Explain why, not what -- the diff shows what changed.
Refs: PROJ-123
Types:
| Type | When to use |
|---|---|
feat | A new feature or user-facing behaviour |
fix | A bug fix |
docs | Documentation changes only |
style | Formatting, whitespace, semicolons (no logic change) |
refactor | Code restructuring without behaviour change |
perf | Performance improvement |
test | Adding or updating tests |
chore | Build, tooling, dependency updates |
ci | CI/CD pipeline changes |
revert | Reverting a previous commit |
Scope is the module, service, or area affected. Use the project's module structure:
feat(appointments): add recurring booking support
fix(auth): handle expired refresh tokens gracefully
chore(deps): upgrade @nestjs/core to 11.0.0
refactor(patients): extract validation into shared dto
test(billing): add integration tests for invoice generation
ci(pipeline): add staging deployment step
What goes in the body:
- Why the change was made, not what changed (the diff shows that).
- Links to relevant Jira tickets (
Refs: VET-42). - Breaking changes flagged with
BREAKING CHANGE:in the footer.
feat(api): switch patient ID format from UUID v4 to ULID
ULIDs are sortable by creation time, which eliminates the need for a
separate created_at index on the patients table. The migration converts
existing UUIDs to ULIDs preserving the original creation timestamp.
BREAKING CHANGE: Patient IDs in API responses are now 26-character ULIDs
instead of 36-character UUIDs. Frontend clients must update ID validation.
Refs: VET-312
Pull request workflow
PRs are the unit of change. Every change reaches a protected branch through a PR -- no direct pushes, no exceptions. The PR workflow integrates with Code Review practices and feeds into CI/CD.
PR lifecycle:
- Create the branch from
development(ormainfor hotfixes). - Develop in small commits. Commit frequently with meaningful messages. You can squash later.
- Push and open a PR. Fill in the PR template -- summary, what changed, how to test, linked Jira ticket.
- CI runs automatically. Lint, tests, security scans, type checks. A failing pipeline means the PR is not ready for review.
- Request review. Assign at least one reviewer. For architectural changes, assign the project's technical lead.
- Address feedback. Push additional commits -- do not force-push during review, as it makes it harder for reviewers to see what changed.
- Merge once approved and CI passes. Delete the source branch.
PR template (Bitbucket):
## What
Brief description of what this PR does.
## Why
Link to the Jira ticket and explain the motivation if it's not obvious from the ticket.
## How to test
Steps for the reviewer to verify the change locally or in the QA environment.
## Checklist
- [ ] Self-reviewed the diff before requesting review
- [ ] Tests added or updated
- [ ] Documentation updated if behaviour changed
- [ ] No secrets or credentials in the code
- [ ] Jira ticket linked
PR size: Target under 400 lines of meaningful diff. See Code Review for detailed guidance on PR structure and stacking.
Merge strategy
Squash merge for feature branches into development. Each feature branch becomes a single, clean commit on the integration branch. This keeps development history readable -- one commit per feature or fix, with the conventional commit message as the squash message.
# Bitbucket: set default merge strategy to "Squash" for development branch
# The squash commit message should be the PR title in conventional commit format
# Example: feat(appointments): add recurring booking support (VET-42)
Regular merge (no squash) from development to staging and from staging/release to main. These are integration merges that preserve the commit history of what shipped in each sprint. A merge commit on main marks a release boundary.
Why squash for features? Feature branches accumulate "WIP" commits, fixups, and review feedback commits that add noise to the integration branch. Squashing collapses all of that into a single coherent commit. The detailed commit history is still available in the closed PR if anyone needs it.
Why not squash everywhere? Squashing development into main would lose the individual feature commits, making git bisect less useful and making it impossible to see which features shipped in which release.
Rebase policy: Avoid rebasing shared branches. Rebasing a branch that someone else is working on rewrites history they've already pulled. If your feature branch falls behind development, merge development into your branch -- do not rebase onto development unless you are the only person working on that branch.
Protected branches and branch policies
Configure these in Bitbucket (Settings > Branch permissions) for every project. These are not suggestions -- they are guardrails that prevent the kind of incidents that cost days to fix.
For main:
- No direct pushes. All changes come through PRs.
- Minimum 1 approval required (2 for critical projects).
- All CI checks must pass before merge.
- No force pushes. History rewriting on
mainis never acceptable. - Only release branches and hotfix branches can target
main.
For development:
- No direct pushes. All changes come through PRs.
- Minimum 1 approval required.
- All CI checks must pass.
- Feature, bugfix, and chore branches target
development.
For staging:
- No direct pushes. Merges from
developmentonly. - CI checks must pass.
Branch deletion: Enable auto-delete of source branches after merge in Bitbucket. Stale branches clutter the repository and create confusion about what is active.
Handling hotfixes
Hotfixes bypass the normal sprint flow because production is broken and waiting for the next sprint is not an option. The process is deliberately short:
- Branch from
main:hotfix/PROJ-789-critical-fix - Fix, test, push. Keep the change as minimal as possible. A hotfix is not a feature.
- Open a PR against
main. Get an expedited review -- the reviewer's job is to verify the fix is correct and scoped, not to do a full code review. - Merge to
main. Deploy to production through the standard pipeline. - Immediately merge
mainback intodevelopment(andstagingif active). The hotfix must reach every long-lived branch so the fix is not lost in the next release.
A hotfix that requires more than a few hours of work is not a hotfix -- it is a prioritised bug that should go through the normal sprint workflow with an expedited timeline.
Release process
The release process bridges source control and CI/CD. This section covers the source control mechanics; pipeline configuration, deployment automation, and release management (stakeholder communication, release documentation, release day procedures) are in CI/CD & Release Process.
Sprint release flow:
- Feature freeze. At the agreed point in the sprint, stop merging features into
development. Only bug fixes and test updates go in after this point. - Merge
developmenttostaging. This triggers a staging deployment. QA validates the sprint scope in staging. - Fix staging issues. If QA finds issues, fix them on bugfix branches that merge to
development(then re-merge tostaging) or directly onstagingif the fix is trivial and must not wait. - Create a release branch (optional, for projects that need it):
release/v1.2.0. This branch exists for final polish and version bumping. Not every project needs this -- if staging is stable, skip it. - Merge to
main. Tag the merge commit with the version:v1.2.0. This triggers the production deployment. - Merge
mainback todevelopment. This ensures any hotfixes or release fixes are in the next sprint's starting point.
Versioning: Use Semantic Versioning for releases. MAJOR.MINOR.PATCH -- increment MAJOR for breaking changes, MINOR for new features, PATCH for bug fixes. Tag every production release on main.
# Tag the release after merging to main
git tag -a v1.2.0 -m "Release v1.2.0 -- Sprint 14"
git push origin v1.2.0
Git hooks for quality gates
Git hooks catch problems before they reach the CI pipeline. They are faster than CI (seconds vs minutes) and provide immediate feedback to the developer.
Local hooks (managed with Husky + lint-staged):
// package.json
{
"husky": {
"hooks": {
"pre-commit": "lint-staged",
"commit-msg": "commitlint --edit $1"
}
},
"lint-staged": {
"*.{ts,tsx}": ["eslint --fix", "prettier --write"],
"*.{json,md,yaml}": ["prettier --write"]
}
}
| Hook | What it does | Why |
|---|---|---|
pre-commit | Runs lint-staged (ESLint + Prettier on staged files) | Catches formatting and lint errors before they pollute the commit history |
commit-msg | Validates the commit message against Conventional Commits via commitlint | Enforces message format consistently, not just when someone remembers |
Project setup: Include Husky configuration in the project template. pnpm install should set up hooks automatically via prepare script. Developers should not need to configure hooks manually.
// package.json
{
"scripts": {
"prepare": "husky install"
}
}
Server-side hooks (Bitbucket): Configure branch permissions and merge checks in Bitbucket. These are the enforcement layer -- local hooks are a convenience, but server-side checks are the guarantee. See "Protected branches" above.
Monorepo vs polyrepo
S&P's standard project structure is a monorepo containing both API and web application in a single repository. This is the default for new projects and the structure used in the engineering-forward template.
project-root/
apps/
api/ # NestJS backend
web/ # Next.js frontend
libs/
shared/ # Shared types, DTOs, utilities
infrastructure/ # Terraform, Docker, CI config
docs/ # Architecture diagrams, ADRs
Why monorepo is the default:
- Atomic changes. A feature that touches the API and the frontend ships as a single PR with a single review. No cross-repo coordination, no version pinning between repos.
- Shared types. DTOs, enums, and validation schemas live in
libs/shared/and are imported by both apps. No code generation, no drift between API contract and frontend types. - Single CI pipeline. One pipeline builds, tests, and deploys both apps. Simpler to maintain than coordinating pipelines across repos.
- Easier onboarding. New developers clone one repo and have everything. No "which repos do I need?" confusion.
When to use polyrepo:
- The API and frontend are maintained by separate teams with independent release cycles (rare at S&P).
- The project includes a shared library consumed by multiple unrelated projects.
- Client requirements mandate separate repositories (e.g., different access controls for frontend and backend code).
- The repository grows large enough that clone/checkout times become a problem (unlikely for most S&P projects).
Document the decision as an ADR. The default is monorepo unless there is a specific reason not to.
.gitignore essentials
Every project starts from the template .gitignore. These entries are non-negotiable:
# Dependencies
node_modules/
# Build output
dist/
.next/
.turbo/
# Environment and secrets
.env
.env.local
.env.*.local
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Test coverage
coverage/
# Logs
*.log
npm-debug.log*
pnpm-debug.log*
The .env.example file is tracked in git with placeholder values. The .env file is never tracked. This is the boundary between "configuration structure" (committed) and "configuration values" (secret). See Security for secrets management.
Critical thinking
The "just push to main" temptation
On a 2-person project with a short timeline, the branching strategy described above can feel like overhead. It is tempting to skip development and push directly to main. Resist this. The branching model exists because S&P's sprint-based shipping means code accumulates before release. Without an integration branch, you lose the ability to do pre-release QA on a stable set of changes. The cost of setting up the branches is measured in minutes; the cost of a bad release is measured in days.
That said, not every project needs staging as a separate branch. For internal tools or projects where the client does not preview releases, development + main is sufficient. Add staging when there is a QA or client review phase between integration and production.
Commit history: clean enough, not perfect
Some teams obsess over a perfectly linear commit history, rebasing everything, squashing every branch. Others let the history become an unreadable tangle of merge commits. Neither extreme is useful.
The goal is a history that supports debugging. Can you run git bisect and find the commit that introduced a bug? Can you read git log --oneline on development and understand what shipped this sprint? If yes, the history is clean enough. If not, tighten the merge strategy.
When Conventional Commits feel wrong
Conventional Commits do not cover every situation. A commit that fixes a bug, adds a test for it, and updates the documentation is legitimately a fix, a test, and a docs change. Pick the primary purpose (the bug fix) and use that as the type. The body can mention the other changes. Do not split it into three commits unless the changes are genuinely independent.
Branch naming in multi-project Bitbucket workspaces
When S&P manages multiple projects in a single Bitbucket workspace, branch names can collide if two projects use the same Jira ticket prefix. The branch naming convention handles this because the Jira key includes the project code (VET-42, CRM-99). If the workspace contains projects without unique Jira prefixes, add the project short code to the branch name: feature/crm/CRM-42-add-contact-export.
Checklist
For every commit
- Commit message follows Conventional Commits format:
type(scope): description - Message describes why, not just what
- Jira ticket referenced in the message body or branch name
- No secrets, credentials, or
.envfiles included
For every PR
- Branch follows naming convention:
type/PROJ-123-short-description - PR targets the correct branch (
developmentfor features,mainfor hotfixes) - PR template is filled in (what, why, how to test)
- CI pipeline passes before requesting review
- PR is under 400 lines of meaningful diff (or description explains why it is larger)
- Source branch is deleted after merge
For every project
-
main,development, andstaging(if needed) branches exist and are protected - Branch permissions are configured: no direct pushes, minimum approvals, CI checks required
- Squash merge is the default for feature branches into
development - Husky + lint-staged + commitlint are configured and work on
pnpm install -
.gitignoreincludes all entries from the template -
.env.exampleexists with placeholder values;.envis gitignored - Auto-delete of merged branches is enabled in Bitbucket
- Versioning follows Semantic Versioning and releases are tagged on
main
For every release
- Feature freeze is communicated to the team
-
developmentis merged tostagingand QA validates in staging - Release is tagged on
mainwith the version number -
mainis merged back todevelopmentafter the release - Hotfixes (if any) are present in all long-lived branches
AI tips
- Generate commit messages from diffs. Paste a
git diffinto an AI tool and ask for a Conventional Commits message. AI is good at summarising what changed; you add the "why" and the Jira reference. This is particularly useful for large refactoring commits where the diff is clear but writing a concise summary takes effort. - Review branch naming. If your team is inconsistent with branch names, paste a list of recent branches and ask AI to flag ones that don't follow the convention. Use this during onboarding to calibrate new team members.
- Draft PR descriptions. Give AI the diff and ask it to generate a PR description following the template. AI saves time on the mechanical parts (listing what changed, suggesting test steps) so you can focus on the "why" and any context the reviewer needs.
- Resolve merge conflicts. For complex conflicts, paste both versions and the common ancestor into AI and ask it to merge them. AI handles mechanical conflicts well (import ordering, adjacent changes). For logic conflicts where both sides changed the same behaviour, AI can suggest the merge but you must validate the result against the intended behaviour.
- Configure branch permissions. Describe your branching strategy and team structure to AI and ask it to generate the Bitbucket branch permissions configuration. This saves time on the initial project setup and ensures no permission is missed.
- Generate .gitignore entries. When adding a new tool or framework to the project, ask AI for the recommended
.gitignoreentries. Cross-check against gitignore.io for completeness.
Resources
S&P internal:
- S&P Git workflow (Confluence) -- Existing workflow documentation (this section supersedes it)
- Engineering-forward template -- Standard project template with branch policies and CI config
- S&P Code Review -- PR review practices and size guidelines
- S&P Security -- Secrets management and security pipeline
- S&P CI/CD & Release Process -- Pipeline configuration and deployment automation
Standards and references:
- Conventional Commits -- Commit message specification
- Semantic Versioning -- Release versioning standard
- Gitflow -- Original Gitflow model (S&P uses a simplified variant)
- Microsoft Code-with-Engineering Playbook -- Source Control -- Microsoft's source control guidance
- 12-Factor App -- Codebase -- One codebase, many deploys
Tools:
- Husky -- Git hooks for Node.js projects
- lint-staged -- Run linters on staged files
- commitlint -- Lint commit messages against Conventional Commits
- gitignore.io -- Generate
.gitignorefiles for any stack - Bitbucket Branch Permissions -- Configure protected branches