Version Control and Developer Tooling Questions
The everyday toolchain of software work: version control with Git (branching, merging, rebasing, conflict resolution, using git bisect to find a regression), command-line and shell proficiency for day-to-day navigation, log inspection, and troubleshooting, IDE and editor workflows, build systems and package/dependency management (npm, Maven, pip, Gradle, CocoaPods, and embedded/cross-compilation toolchains), and the growing practice of AI-assisted coding: using, reviewing, and verifying AI-generated code and tests. Deliberately generic across languages and stacks; language- and domain-specific frameworks live in their own categories. This topic covers a developer's individual command of these tools, not: writing durable shell automation and glue scripts (Shell Scripting and Automation owns that), producing, versioning, and publishing build artifacts or container images (Build Automation and Artifact Management owns that), release cadence and change governance (Release Management and Change Control owns that), or diagnosing a live production incident end to end (Performance Troubleshooting and Incident Response and the Observability topics own that).
Your CI uses shallow clones to speed builds but you now need to run git bisect inside CI to find a regression introduced months ago. How would you modify the CI job to obtain the necessary history on-demand while keeping the common case fast? Provide commands and a policy for when to fetch more history.
Sample Answer
Direct answer
Keep the default CI clone shallow for speed, and only fetch the additional history a bisect actually needs, on demand, in the specific job that's running it, rather than deepening every routine build.
Structured elaboration and worked example
- Routine builds stay untouched, shallow as today:
git clone --depth 1 https://example.com/repo.git
- A dedicated bisect job, triggered manually or by a "regression, needs history" label, fetches just enough additional history before starting, rather than the full history by default:
# add enough recent commits to cover the suspected regression window
git fetch --deepen=200 origin
# or, if you know roughly when the regression window started
git fetch --shallow-since="2026-01-01T00:00:00Z" origin
# only if targeted deepening isn't enough
git fetch --unshallow origin
--deepen extends a shallow clone's history by a fixed number of additional commits, rather than replacing it; --shallow-since fetches all commits back to a given date, useful when you have a rough idea of when the regression window starts. Both are cheaper than a full --unshallow, so try them first and only fall back to --unshallow if the regression turns out to be older than expected.
- Run the bisect with an automated test script, so it doesn't need a human to judge good or bad at every step:
git bisect start HEAD <last-known-good-commit>
git bisect run ./ci/regression-check.sh
git bisect reset
git bisect run automates the whole search: git repeatedly checks out a candidate commit and runs your script, treating a zero exit code as "good" and a nonzero exit code as "bad", narrowing the range by binary search each time. The script needs to reliably distinguish good from bad on its own; an inconsistent test makes bisect converge on the wrong commit.
Policy for when to fetch more history
Never deepen for routine pull request or merge builds, since that reintroduces exactly the cost the shallow clone was meant to avoid. Only deepen in a job explicitly requesting a bisect or regression search, or one that failed a first attempt at a shallow bisect and needs to widen its window. Log the depth fetched, before and after, so a slow or unexpectedly large deepen is visible and auditable rather than a silent cost.
Trade-offs and pitfalls
--deepen and --shallow-since are not equivalent: one is commit-count based, the other is date based, and picking the wrong one for a squashed or infrequently-committed history can still leave you short of the actual regression window on the first attempt, forcing a second, larger fetch. Caching git objects between bisect runs on the same runner helps, but only if the cache correctly distinguishes a shallow state from a deepened one; a naive cache that assumes everything is shallow can serve stale, incomplete history to a job that actually needed more.
How would you use an AI tool to draft unit tests for a feature preprocessing function, and what edge cases must you add manually so the tests do not create false confidence?
Sample Answer
Direct answer
I would let the AI tool draft the happy-path tests first, the normal, expected-input cases, since that is what it is genuinely good at, and then add the edge cases myself: empty input, missing values across an entire column, out-of-range values, and anything that reveals a design decision the function never actually made explicit. The reason this matters is that AI-generated tests tend to mirror the implementation rather than the specification, so a test suite that only exercises the happy path can pass at 100% while still creating false confidence about what the function actually does when it meets bad input.
Worked example
Take a small preprocessing function that clips an age value into a valid range and fills missing values with the median of the values present in the batch:
def clean_age(records):
"""AI-drafted happy-path preprocessing: clip age to [0, 120] and fill
missing values with the median of the present values. Returns a new list."""
present = sorted(r["age"] for r in records if r.get("age") is not None)
n = len(present)
if n == 0:
median = 0
elif n % 2 == 1:
median = present[n // 2]
else:
median = (present[n // 2 - 1] + present[n // 2]) / 2
cleaned = []
for r in records:
age = r.get("age")
if age is None:
age = median
age = max(0, min(120, age))
cleaned.append({**r, "age": age})
return cleaned
# --- AI-drafted happy-path tests ---
def test_normal_values():
out = clean_age([{"age": 25}, {"age": 40}])
assert out == [{"age": 25}, {"age": 40}]
def test_fills_missing_with_median():
out = clean_age([{"age": 20}, {"age": 30}, {"age": None}])
assert out[2]["age"] == 25 # true median of [20, 30] is (20+30)/2 = 25
# --- Manually added edge cases the draft did not cover ---
def test_empty_input():
assert clean_age([]) == []
def test_all_missing_column():
out = clean_age([{"age": None}, {"age": None}])
assert out == [{"age": 0}, {"age": 0}] # median fallback exposes a real bug
def test_out_of_range_clips():
out = clean_age([{"age": -5}, {"age": 999}])
assert out == [{"age": 0}, {"age": 120}]
for name, fn in list(globals().items()):
if name.startswith("test_"):
fn()
print(f"{name}: PASS")
Running this actually prints:
test_normal_values: PASS
test_fills_missing_with_median: PASS
test_empty_input: PASS
test_all_missing_column: PASS
test_out_of_range_clips: PASS
Every test passes, including the two AI-drafted happy-path tests, so a quick look would suggest the function is solid. But test_all_missing_column is the one that exposes the real problem: when an entire batch has no age values at all, the function silently defaults every row to age 0 rather than raising an error or flagging the column as unusable. That is exactly the kind of false confidence a happy-path-only test suite creates: every test is green, and the function still has a design bug that would quietly corrupt downstream predictions if it ever hit a batch missing that entire column in production.
Edge cases to add manually
- Empty input entirely.
- An entire column of missing values (not just one missing value among many present ones), since that changes the fallback behavior in ways a single missing value never exercises.
- Out-of-range and boundary values, to confirm clipping actually happens where you expect it, including at the exact boundary (0 and 120 themselves).
- Duplicate rows or an unexpectedly missing required column, if the function assumes a fixed schema.
- An aggregate statistic (like a median) computed over an even number of present values, checked against its actual mathematical definition rather than against whatever the first implementation happens to output, since a naive
sorted(x)[len(x)//2]silently returns the upper-middle element instead of the true average for even-length input. - Anything the function does implicitly that was never specified: here, "what happens when there is no data to compute a median from" was never in the spec, and the AI-drafted tests never exercised it.
Trade-offs and pitfalls
Relying only on AI-drafted tests optimizes for coverage of the code as written, which is circular: the tests confirm the implementation matches itself, not that the implementation matches the actual business requirement. Good tests should break when the intended behavior changes, not only when the syntax changes, and that discipline is exactly what the manually added edge cases above are for.
Linters and formatters: propose an adoption plan that minimizes developer friction while ensuring code quality. Discuss pre-commit vs CI enforcement, auto-fix vs review changes, incremental rollout, and how to avoid large rewrite PR noise.
Sample Answer
Direct answer
Use pre-commit hooks for cheap, deterministic, auto-fixable rules (formatting) and CI as the real gate for anything you actually want to block a merge on, since a pre-commit hook can be skipped. Roll new rules out as warnings before making them blocking, and do the one-time reformat of existing code as a single dedicated commit rather than scattering it across normal pull requests.
Structured elaboration
- Pre-commit vs CI: pre-commit hooks run locally and are ideal for cheap, deterministic, auto-fixable checks like formatting, so a badly formatted diff never even gets pushed. CI is the actual enforcement gate: a pre-commit hook can be bypassed with
git commit --no-verifyor simply never installed, so CI re-running the same checks is what cannot be skipped by an individual. - Auto-fix vs review changes: formatting issues should auto-fix (or fail the local commit until run), they are not a matter of judgment. Linting issues that require a human decision, for example "this variable really is unused, delete it or was that intentional," should surface as a review comment rather than a silent automated change that could alter behavior.
- Incremental rollout: turn new rules on as warnings first so the team can see the actual violation volume before it becomes a hard CI failure. If the tool supports it, enable "fail only on new violations" against a baseline, so old code does not have to be fixed before anyone can merge anything.
- Avoiding large rewrite PR noise: run the full-codebase reformat as one dedicated commit, reviewed and merged on its own, never mixed into a feature PR. Immediately register that commit's hash with
git config blame.ignoreRevsFile .git-blame-ignore-revs(a Git feature since 2.23) sogit blameskips over the mass reformat and keeps attributing lines to whoever actually wrote the logic.
Worked example
An illustrative rollout sequence for adopting a formatter and linter (for example Prettier and ESLint) on an existing JavaScript codebase:
- Add config files and enable pre-commit auto-fix for formatting only; CI still passes, linting is not yet enforced.
- Reformat the whole repository in one commit, and add that commit's hash to
.git-blame-ignore-revs. - Turn on linting in CI as non-blocking (report only) for a couple of weeks so the team can see and fix real violations at their own pace.
- Flip linting to blocking in CI. Pre-commit now runs both the formatter and the linter locally, so almost nothing reaches CI red in the first place.
Trade-offs & pitfalls
- Reformatting files opportunistically as people happen to touch them, instead of doing the dedicated one-time reformat, scatters unrelated whitespace diffs across every future PR for months. This is the single biggest source of reviewer friction with linting rollouts.
- Making a brand-new rule blocking in CI on day one, before anyone has seen the violation volume, tends to get the whole initiative reverted by a frustrated team; a warning period buys buy-in.
- Relying on pre-commit alone with no CI enforcement makes the rule optional in practice, since
--no-verifyand simply not installing the hook both bypass it silently.
Design a migration plan to split a very large monorepo into multiple smaller service-specific repositories while preserving commit history for each service and minimizing disruption to active development. Include tooling, steps to synchronize changes during migration, CI adjustments, and how to handle shared libraries.
Sample Answer
Direct answer
Split the monorepo one service at a time, not all at once: extract each service's history into its own repository using a history-rewriting tool (git filter-repo, the modern, actively-maintained replacement for the deprecated git filter-branch), run the old and new repositories side by side behind a sync bridge while teams migrate, and handle shared libraries by turning them into independently versioned packages rather than copy-pasting or nesting repos inside repos. The riskiest part isn't the git mechanics, it's sequencing the cutover so nobody is developing against a moving target.
Structured elaboration
1. Inventory and boundary-drawing. Map every directory to a candidate service, and separately map cross-service dependencies (which directories import which others, which share a build config or a CI job). Anything imported by more than one future-service is a shared library candidate, not part of any single service's extraction.
2. History extraction per service. For a service living at services/payments, clone the monorepo and run:
git clone https://example.com/monorepo.git payments-service
cd payments-service
git filter-repo --path services/payments --path-rename services/payments/:
--path services/payments keeps only commits that touched that subtree (dropping everything else); --path-rename moves those files to the new repo's root. This preserves every retained commit's author, date, and message, git log in the new repo shows real history, not a single "import" commit. This is illustrative of the documented git filter-repo flags, I have not run it against your actual monorepo, treat the exact output as something to verify on a disposable clone first.
3. Shared libraries. Extract shared code into its own repo the same way, then publish it as a versioned package (an internal npm/PyPI/Maven registry entry) that consuming services pin a version of, rather than a submodule. Submodules recreate a lot of the monorepo's "everything moves together" coupling in a more fragile form; a versioned package makes the shared library's own release cadence explicit and lets each service upgrade on its own schedule.
4. Coexistence and sync bridge. Until a service's cutover is final, changes may still land in the monorepo's old path. A one-way sync job (scheduled git subtree push-style mirroring, or simply a freeze-the-old-path rule the moment the new repo becomes canonical) keeps the two from silently diverging. Keep this window short and explicit, indefinite dual-maintenance is where these migrations actually go wrong.
5. CI adjustments. Each new repo gets its own, smaller pipeline (faster, since it builds only that service). The monorepo's CI drops the jobs for paths that have moved out. Cross-service integration testing, previously "free" because everything lived in one checkout, needs a deliberate replacement: either a nightly pipeline that checks out several repos at pinned versions and runs integration tests, or contract tests (each service publishes and verifies against a shared, versioned specification of another service's API, so both sides can confirm they're still compatible without either one checking out the other's code) between services that don't require a shared checkout at all.
6. Cutover order. Migrate the least-coupled service first as a dry run to shake out tooling problems cheaply, then proceed in dependency order, leaf services (nothing else in the monorepo still imports them directly) before services other in-monorepo code still depends on internally, so you never extract something that's still needed via a same-repo import.
flowchart LR
A["Monorepo: services/payments"] -->|"git filter-repo --path services/payments"| B["New repo: payments-service, full history"]
A -->|"sync bridge during coexistence"| B
B -->|"cutover: freeze old path, redirect CI"| C["payments-service is canonical"]
D["Shared library code"] -->|"extract, version, publish"| E["shared-lib package in registry"]
A -->|"consumes shared-lib as pinned dependency"| E
C -->|"consumes shared-lib as pinned dependency"| E
Worked example
Say services/payments and services/billing both import libs/currency. Extract libs/currency first as currency-lib, publish it as version 1.0.0 to an internal package registry. Extract payments-service next; its package.json/equivalent now declares currency-lib@^1.0.0 instead of a relative import. Run both the old monorepo path and the new payments-service repo for one sprint with the sync bridge active, confirm the new repo's CI is green and a real deploy from it works, then freeze and delete services/payments from the monorepo in one deliberate commit, not silently.
Trade-offs and pitfalls
History-rewriting tools are genuinely reliable for this, but they're also genuinely disruptive: every clone of the new repo before a re-run of filter-repo with different flags is now stale, so do all extraction on disposable clones and only push once. Teams consistently underestimate the CI/tooling migration effort relative to the history-rewrite step, the git filter-repo command itself takes minutes; rebuilding cross-service integration testing without a shared checkout can take much longer. A "big bang, migrate everything this weekend" cutover minimizes coexistence-drift risk but maximizes blast radius if something breaks; a slow, staggered cutover is safer per-step but extends the coexistence window where the sync bridge itself becomes a source of subtle bugs (a commit landing in the wrong repo, a sync job silently failing). Pick staggered-but-time-boxed over either extreme.
Explain the trade-offs between trunk-based development and long-lived feature branches for teams practicing continuous delivery. Specifically discuss how each impacts merge conflicts, CI performance, code review practices, and the use of feature flags to shorten branch lifetimes.
Sample Answer
Direct answer
Trunk-based development means every contributor integrates into the trunk (usually main) at least daily, via branches that live under a day, if branches are used at all. Long-lived feature branches keep work isolated for days or weeks before merging. The trade-off is when you pay the integration cost: trunk-based development pays it constantly, in small pieces, while long-lived branches defer it to one large merge later. Trunk-based development only works well if feature flags (a runtime toggle that hides an incomplete code path from users) exist to decouple "merged into trunk" from "visible to users," since code integrates long before it's actually finished.
Structured elaboration
| Dimension | Trunk-based development | Long-lived feature branches |
|---|---|---|
| Merge conflicts | Small and frequent, each day's conflict involves at most a day's worth of divergence, usually trivial to resolve | Rare but large, weeks of divergence collide at once, often requiring careful, high-stakes conflict resolution right before a deadline |
| CI performance | Every merge to trunk must pass CI fast and reliably, since it gates all subsequent work; CI becomes genuinely critical infrastructure | CI runs per-branch during development, but the real test (does this integrate cleanly with everything else that also changed) only happens at merge time, often much later |
| Code review practices | Reviews happen on small, frequent, easy-to-reason-about diffs (a day's work at most) | Reviews happen on large diffs representing weeks of work, harder to review thoroughly, more likely for real issues to get rubber-stamped past a tired reviewer |
| Feature flags | Load-bearing: incomplete work merges into trunk constantly, flags are what keep it invisible to users until it's actually ready | Optional: the branch itself already serves as the "not visible yet" boundary, so flags are a nice-to-have rather than a requirement |
Worked example
A team building a redesigned checkout flow using trunk-based development merges the new checkout's backend, then its UI shell, then its payment step, over several days, each behind a flag defaulted off in production. Each merge is small, reviewed quickly, and tested against the rest of the (unrelated) code that also merged that week. A team doing the same feature on a long-lived branch keeps all of that work isolated for three weeks, then opens one large PR touching dozens of files; by then, main has moved substantially, and the merge itself becomes a multi-hour, high-risk event, often revealing conflicts with unrelated changes that were each individually easy to resolve on the day they happened, but are now tangled together.
Trade-offs and pitfalls
Trunk-based development without real feature-flag discipline is not actually trunk-based development, it's just committing unfinished work directly to production-adjacent code, which is worse than either alternative. It also demands CI that's fast enough and trustworthy enough to gate merges multiple times a day; a slow or flaky pipeline turns "integrate constantly" into "wait in a queue constantly." Long-lived branches feel safer day-to-day (nothing touches trunk until it's "done"), but that safety is an illusion, the actual integration risk doesn't disappear, it accumulates and gets paid all at once, at the worst possible time, right when the feature is under deadline pressure to ship.
Unlock Full Question Bank
Get access to all Version Control and Developer Tooling interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.