Open Source in C++: From Reading Code to Your First Pull
Why Contribute to Open Source?
Working on open source C++ libraries accelerates your skills in ways that proprietary codebases rarely do. You read code written by experienced engineers with different styles, submit to review by people with no obligation to be gentle, and learn how professional C++ projects handle CI, testing, and compatibility. A public PR record also matters when job-hunting — hiring managers can read your actual code.
The barrier is psychological, not technical. Most “good first issue” fixes take a few hours and require understanding only a small slice of the codebase.
Choosing the Right Project
Not every project is beginner-friendly. Look for:
| Signal | What to check |
|---|---|
| Active maintenance | Recent commits, issues responded to within days |
| CONTRIBUTING.md | Detailed build instructions, PR checklist, style guide |
| Good first issue labels | Curated entry points with clear scope |
| CI green on main | If CI is already broken, your PR will fight noise |
| Reasonable review culture | Skim recent PR discussions for tone |
Good C++ starting points:
| Project | Why it’s beginner-friendly |
|---|---|
| spdlog | Small, well-tested logging library; clear code; welcoming maintainer |
| fmt | Header-heavy but modular; detailed issue descriptions |
| Catch2 | Test framework; contributors often start with test-writing PRs |
| nlohmann/json | Enormous user base; many small, well-described issues |
| vcpkg ports | Adding or updating a package port is self-contained and reviewed quickly |
Treat this list as a starting point rather than a recommendation to open PRs today: activity and maintainer availability change, so check the signals in the first table for whichever project you pick. Projects you already use are the best candidates, because you understand the problem the library solves and may already have hit a bug worth fixing.
Harder places to start: LLVM, GCC and Boost (large codebases, long build times, and review processes with their own tooling and conventions), and security-critical crypto libraries, where even small changes get intense scrutiny. They are not closed to newcomers, and LLVM labels beginner issues too, but a first contribution there usually takes much longer to land.
The Fork and Clone Workflow
Every open source contribution follows the same Git workflow:
# 1. Fork on GitHub (click Fork button on the project page)
# This creates YOUR copy at github.com/YOUR_USERNAME/spdlog
# 2. Clone YOUR fork (not the upstream)
git clone https://github.com/YOUR_USERNAME/spdlog.git
cd spdlog
# 3. Add upstream remote so you can pull future changes
git remote add upstream https://github.com/gabime/spdlog.git
# Verify remotes
git remote -v
# origin https://github.com/YOUR_USERNAME/spdlog.git (fetch)
# origin https://github.com/YOUR_USERNAME/spdlog.git (push)
# upstream https://github.com/gabime/spdlog.git (fetch)
# upstream https://github.com/gabime/spdlog.git (push)
# 4. Fetch upstream to see what's on their main
git fetch upstream
Building and Running Tests
Before touching any code, get the project building and tests passing locally:
# spdlog CMake build
cmake -B build -DSPDLOG_BUILD_TESTS=ON -DCMAKE_BUILD_TYPE=Debug
cmake --build build -j$(nproc)
cd build && ctest --output-on-failure
# fmt CMake build
cmake -B build -DFMT_TEST=ON
cmake --build build -j$(nproc)
cd build && ctest --output-on-failure
If the build fails, check CONTRIBUTING.md for required dependencies. Common issues:
# Missing fmt dependency (spdlog often bundles it, but confirm)
git submodule update --init --recursive
# Compiler version mismatch
cmake -B build -DCMAKE_CXX_STANDARD=17 -DCMAKE_CXX_COMPILER=clang++
Run the full test suite before making any changes. You need a green baseline — if a test fails before your change, that is not your bug to fix (but you can note it).
Look at the CI configuration (.github/workflows/*.yml) at the same time. It tells you what the project actually promises to support: which compilers and versions, which C++ standards, which operating systems. A library that still builds with -std=c++11 on GCC 4.8 will reject a fix that uses std::optional or structured bindings, no matter how clean it is. The fastest way to avoid that round trip is to build locally with the oldest standard in the matrix, for example -DCMAKE_CXX_STANDARD=11, before pushing.
Finding and Understanding the Issue
Once local builds are green, find an issue to work on:
# GitHub search for C++ good first issues
https://github.com/search?q=is:issue+is:open+label:"good+first+issue"+language:C%2B%2B
When you find a candidate:
- Read the issue fully — understand the expected vs. actual behavior
- Find the relevant code:
git grep,find . -name "*.h", or use your IDE’s symbol search - Reproduce the problem: write a tiny
test.cppthat demonstrates the bug, or run the failing test - Read the test file for the affected module — you will add a test alongside your fix
- Say you are working on it. A short comment on the issue (“I’d like to take this; my plan is to X”) avoids two people fixing the same bug, and it gives the maintainer a chance to say “that approach won’t work because of Y” before you have written anything. For anything larger than a typo, this one comment saves more time than any other step.
The last step is the one I would not skip. The most discouraging outcome for a first contribution is a finished, tested PR that is closed because the maintainers had already decided on a different fix in a discussion you did not see, or because someone else’s PR for the same issue was merged the day before.
# In spdlog: find files related to "async" logging
grep -r "async_logger" include/ --include="*.h" -l
grep -r "async_logger" tests/ --include="*.cpp" -l
Creating Your Branch
Never work on main. Create a descriptive branch name:
# Sync your fork's main with upstream first
git fetch upstream
git checkout main
git merge upstream/main # fast-forward if no local commits
git push origin main # keep your fork's main in sync
# Create a topic branch
git checkout -b fix/async-logger-flush-on-destroy
# or
git checkout -b docs/add-sinks-example
# or
git checkout -b feat/add-daily-file-sink-rotation
Writing the Fix
A worked example, with a hypothetical bug so the steps are easy to follow: suppose spdlog’s async logger did not flush on destruction. (The real library handles this through its thread pool; the code below illustrates the shape of a fix and its test, not an actual spdlog patch.) You find the destructor in include/spdlog/async_logger-inl.h:
// Before (broken)
SPDLOG_INLINE spdlog::async_logger::~async_logger() {
// does nothing — messages in the queue may be lost
}
// After (fixed)
SPDLOG_INLINE spdlog::async_logger::~async_logger() {
SPDLOG_TRY {
flush(); // drain the queue before the thread pool shuts down
}
SPDLOG_LOGGER_CATCH(source_loc{})
}
Write a test that covers your fix:
// tests/test_async.cpp — add a new test case
TEST_CASE("async logger flushes on destruction", "[async]") {
auto tp = std::make_shared<spdlog::details::thread_pool>(8, 1);
auto sink = std::make_shared<spdlog::sinks::test_sink_mt>();
{
auto logger = std::make_shared<spdlog::async_logger>(
"test", sink, tp, spdlog::async_overflow_policy::block);
logger->info("message 1");
logger->info("message 2");
// destructor called here — should flush
}
// Messages must have reached the sink
REQUIRE(sink->msg_counter() == 2);
}
Rebuild and run:
cmake --build build -j$(nproc)
cd build && ctest -R "async" --output-on-failure
A reviewer would push back on this test, and it is worth seeing why before they do. An async logger hands messages to a worker thread, and a flush in an async design typically enqueues a flush request rather than waiting for it. So the REQUIRE can run before the worker has delivered both messages, and the test passes or fails depending on thread scheduling. Flaky tests are one of the most common reasons first PRs get stuck in review, because they fail in CI on a loaded machine while passing every time on your laptop. Make the test deterministic: destroy the thread pool too (its destructor joins the worker), or wait on a condition the sink signals, instead of relying on timing. Running the test in a loop (ctest -R async --repeat until-fail:200) is a cheap way to see flakiness before CI does.
Also check the test fails without your fix. Temporarily revert the change to the destructor and run it again; a test that passes either way documents nothing.
Style and Formatting
Before committing, apply the project’s formatter:
# clang-format — most C++ projects use it
clang-format -i include/spdlog/async_logger-inl.h tests/test_async.cpp
# Check for issues without modifying
clang-format --dry-run --Werror file.cpp
# If the project has a script
./scripts/run-clang-format.sh
Check the .clang-format file in the root. Never override formatting choices — even if you disagree, a consistent style across the codebase is more important than your preference.
Committing with Conventional Commits and DCO
Commit message conventions differ between projects. Some use Conventional Commits, many simply want a short imperative summary line, and some (LLVM, for example) prefix the component in brackets. Read git log --oneline -20 on the main branch and copy what you see. The Conventional Commits format looks like this:
type(scope): short imperative description
Longer explanation if needed.
Fixes #1234
Types: fix, feat, docs, refactor, test, perf, ci, chore
With DCO sign-off (-s):
git add include/spdlog/async_logger-inl.h tests/test_async.cpp
git commit -s -m "fix(async_logger): flush queue on destruction
Without an explicit flush() call in the destructor, messages buffered
in the async queue could be dropped when the logger went out of scope
before the thread pool finished processing them.
Fixes #789"
The -s flag appends a Signed-off-by: Your Name <[email protected]> line. Projects that require a DCO check for it in CI and block the PR without it. The name and email come from your Git configuration, and the check usually compares them with the commit author, so a commit made with a different email (for example, a work address on one machine) fails even with -s.
Opening the Pull Request
Push your branch:
git push -u origin fix/async-logger-flush-on-destroy
Then open a PR on GitHub. A good PR description:
## Summary
The async logger destructor did not call `flush()`, causing messages in
the async queue to be dropped when the logger went out of scope before
the thread pool finished processing them.
## Changes
- `async_logger::~async_logger()`: added `flush()` inside SPDLOG_TRY block
- `tests/test_async.cpp`: added test case `async logger flushes on destruction`
## How to test
```bash
cmake --build build && cd build && ctest -R "async" --output-on-failure
```
Fixes #789
Keep PRs single-purpose: one bug = one PR. Reviewers won’t merge a PR that fixes a bug AND reorganizes unrelated code — it makes review harder and bisect messier.
Surviving Code Review
Reviewers will ask for changes. Common feedback types:
Style changes: easy — just apply the requested format.
Test coverage: add more test cases to exercise edge cases.
API questions: the reviewer thinks your fix changes a public interface in a breaking way — discuss in the PR thread.
Alternative approach: the reviewer suggests a fundamentally different implementation. Ask why before rewriting — sometimes it is preference, sometimes it is a real constraint.
In C++ libraries, the constraints behind review comments are often invisible to a newcomer, so it helps to know the usual ones. ABI and API stability: adding a data member to a public class, changing a default argument or reordering virtual functions can break users who link against a prebuilt version, even when their code still compiles. Header cost: in a header-only or header-heavy library, a new #include <regex> in a public header adds compile time for every user. Supported standards and compilers: see the CI matrix above. No new dependencies: a fix that pulls in another library is almost never accepted. When a maintainer asks for a less elegant version of your change, one of these is usually the reason.
Responding:
# After making requested changes
git add -u
git commit -s -m "review: address feedback from @maintainer
- move flush() call inside SPDLOG_TRY block
- add test for overflow_policy::overrun_oldest behavior"
git push origin fix/async-logger-flush-on-destroy
# The PR updates automatically
Whether to force-push depends on the project. Some prefer follow-up commits during review and squash on merge, so a force-push makes it hard to see what changed since the last review. Others ask you to keep a clean history and rebase or squash yourself before merging. If CONTRIBUTING.md does not say, add commits during review and ask before rewriting history; when you do force-push, use git push --force-with-lease, which refuses to overwrite commits you have not seen.
Common Errors and Fixes
| Error | Cause | Fix |
|---|---|---|
| CMake can’t find dependency | Missing system package | Check README; install with apt/brew/vcpkg |
| CI fails clang-format | Formatting difference | clang-format -i your changed files |
| CI fails on Windows but passes locally | Path separator, MSVC extension | Test on Windows or use GitHub Actions matrix |
| DCO check fails | Missing Signed-off-by line | git commit --amend -s or git rebase --signoff |
| PR shows merge conflicts | Upstream moved while you worked | git fetch upstream && git rebase upstream/main, resolve, then git push --force-with-lease |
| Test fails on CI but passes locally | Environment difference | Check if test uses hardcoded paths, clocks, or OS APIs |
Frequently Asked Questions (FAQ)
Q. The DCO check failed on my open PR. How do I fix it without making a mess of the review?
A. Add the missing Signed-off-by line with git commit --amend -s for the last commit or git rebase --signoff for several. Both rewrite history, so this is the one case where you have to force-push an open PR; use git push --force-with-lease and leave a short comment saying you only added sign-offs, so reviewers know the code itself did not change. Setting up git commit -s from the start avoids the problem.