GitLab / GitHub
Git internals, branching strategy, PR workflows, and platform engineering on GitHub/GitLab.
How does Git actually work — what's in the .git directory?
Git is a content-addressed object database with a commit graph on top:
- Objects (
.git/objects/) — four types, each named by the SHA of its content: blobs (file contents), trees (directories: names → blob/tree hashes), commits (a tree hash + parent commit(s) + author + message), tags. Same content = same hash = stored once - Refs (
.git/refs/) — branches and tags are just 41-byte files containing a commit hash. A branch is a movable pointer, nothing more - HEAD — a pointer to the current ref (or directly to a commit = detached HEAD)
- The index/staging area — the snapshot being assembled for the next commit
The consequences that make everything click:
- Commits are snapshots, not diffs (diffs are computed on demand) — which is why checkout of any commit is fast
- 'Deleting a branch' deletes a pointer; the commits remain until garbage-collected (and
reflogremembers where pointers were — the undo button people don't know they have) - History 'rewriting' never mutates commits — it creates new commits and moves pointers; the old ones linger in reflog for ~90 days
One-liner: 'Git is a hash-linked snapshot store where branches are 41-byte pointers — once that lands, rebase, reset, and reflog stop being scary.'
merge vs rebase — what does each actually do, and what's your team policy?
- Merge — creates a merge commit with two parents, joining histories: truthful (shows what actually happened, when branches diverged) but noisy (a busy repo's graph becomes spaghetti)
- Rebase — replays your commits on top of the new base, creating new commits (new hashes): linear, clean history — at the cost of rewriting (the replayed commits are different objects; anyone who had the old ones now diverges)
The iron rule: never rebase shared history — rebasing a branch others have pulled forces them into conflict hell. Your unpushed/personal branches: rebase freely.
The policy that most mature teams land on:
- Feature branches: rebase on main to stay current (instead of merge-from-main 'update' commits polluting the branch)
- Landing PRs: squash merge — one PR = one clean commit on main with the PR reference; the messy WIP history stays in the PR record where it's useful
- main is linear, revertable commit-by-commit, and every commit builds (which squash guarantees better than merge)
Interview nuance worth adding: git pull --rebase as the default (avoids accidental merge commits from routine syncs), and the honest counter-position — teams that value true history (kernel-style) merge deliberately and that's legitimate; what's not legitimate is having no policy and a main history nobody can read.
Explain reset --soft/--mixed/--hard, revert, and checkout — and which ones are safe on shared branches.
reset moves the current branch pointer to another commit; the flags control what happens to your files:
--soft— pointer moves; index and working tree untouched (changes stay staged) — 'recombine my last 3 commits' (reset --soft HEAD~3then commit once)--mixed(default) — pointer + index reset; working tree kept (changes present, unstaged) — 'unstage everything'--hard— pointer + index + working tree reset: uncommitted work is destroyed (committed work is reflog-recoverable; uncommitted is genuinely gone)
revert — creates a new commit that undoes an earlier one. History moves forward; nothing is rewritten.
checkout/switch/restore — move HEAD between branches (switch), or restore file contents (restore) — the old overloaded checkout does both, which is why the two new commands exist.
The shared-branch rule (the actual question): on anything pushed and shared, revert is the only polite undo — reset rewrites history and force-pushing it breaks every collaborator and CI. Reset is for local surgery; revert is for public mistakes.
The rescue kit to mention: git reflog — every HEAD movement for ~90 days; 'I hard-reset the wrong thing' is fixed by reset --hard HEAD@{1}. Knowing reflog turns most Git disasters into 30-second recoveries.
What's the difference between fetch and pull, and what is origin/main exactly?
origin/main is a remote-tracking ref — your local, read-only record of where main pointed on the remote the last time you asked. It's not live; it updates only on fetch/pull/push.
git fetch— download new objects and update remote-tracking refs (origin/*). Touches nothing of yours — your branches, index, and working tree are unchanged. Fetch is always safegit pull= fetch + integrate into your current branch: merge by default (surprise merge commits), or rebase with--rebase(setpull.rebase = trueglobally and forget)
Why the distinction matters in practice:
fetchthen inspect (git log main..origin/main— what's incoming?) is the deliberate workflow;pullis fetch + immediate action sight-unseen- 'Your branch is behind origin/main' compares against the cached ref — stale until you fetch; CI/scripts that don't fetch first reason about old state
- Deleted remote branches linger as stale tracking refs —
fetch --prune(orfetch.prune = true) keeps the view honest
One-liner: 'fetch updates your knowledge of the remote, pull updates your branch — and origin/main is a bookmark of last contact, not a live feed.'
Walk me through a professional PR/MR workflow from branch to merge.
- Branch from fresh main:
git switch -c feat/payments-retry— short-lived by design (days, not weeks; a two-week-old branch is a merge-conflict subscription) - Commit in reviewable units with messages that explain why (the diff shows what) — conventional commits (
feat:,fix:) if the repo automates changelogs/versioning from them - Push and open the PR early — draft PRs invite direction-checking before polish; the description states the problem, the approach, the testing done, and links the issue. Small PRs (< ~400 lines) get reviewed in hours; 2,000-line PRs get skimmed and rubber-stamped — size discipline IS review quality
- Automation runs: CI (build/test/lint/scans) posts status checks; CODEOWNERS auto-assigns the right reviewers; branch protection makes both mandatory
- Review rounds: respond to every comment (fix, or discuss — silent-ignore erodes trust); push fixes as new commits during review (reviewers can see what changed since their pass), rebase/cleanup at the end if policy wants it
- Stay current: rebase on main (or use the platform's update button) — the merge gate requires up-to-date-with-main so tests mean something
- Merge via the queue/button per repo policy (squash, typically), branch auto-deleted, deploy pipeline takes over from main
What interviewers listen for: small-PR discipline, review as conversation not gate-keeping, and everything enforced by platform config (protection rules, CODEOWNERS, required checks) rather than by memory and goodwill.
GitFlow vs trunk-based development — compare honestly and tell me what you'd run.
GitFlow: long-lived develop + main, release branches, hotfix branches, feature branches merged to develop.
- Born for versioned, shipped software (installers, mobile apps, on-prem releases) where multiple versions live in the field and release stabilization is a real phase
- The cost: long-lived branches = big divergence = painful merges; 'develop vs main' state confusion; hotfix choreography; and it actively fights continuous deployment (integration is deferred by design)
Trunk-based: everyone merges small changes to main frequently (at least daily); main is always releasable; releases are tags/deployments off main.
- Requires the supporting cast: strong CI, feature flags (merge incomplete work dark — decouples merge from release), small-PR culture, and fast review turnaround
- The payoff: integration pain amortized to near-zero, real continuous delivery, and DORA research consistently correlating it with elite delivery performance
My answer: trunk-based (with short-lived feature branches + PRs — 'scaled trunk-based') for services and anything continuously deployed; release branches only where versioned artifacts genuinely demand them (mobile, on-prem) — and even then, cut from trunk late rather than living in GitFlow permanently.
How do you undo things safely: a bad commit on main, a bad merge, and a force-push accident?
Bad commit already on main (shared): git revert <sha> — a new inverse commit; history intact, CI/CD flows normally. For a range: revert --no-commit sha1..sha3 then one commit. Never reset+force-push main to 'remove' it — every clone and the platform's PR history diverge.
Bad merge: git revert -m 1 <merge-sha> — the -m 1 picks which parent is 'mainline' (usually 1 = the branch you merged into). The famous trap: after reverting a merge, re-merging the same branch later brings nothing — Git considers those commits already merged (the revert undid their changes, not their presence in history). The fix: revert the revert before re-merging, or rebase the branch to new SHAs. Knowing this trap is a senior tell.
Force-push accident (someone nuked a shared branch):
- Platform first: GitHub/GitLab retain the old SHA — the branch's previous head is in the push audit/events API (and often in someone's PR page);
git push origin <old-sha>:branchrestores instantly - Anyone with the old state: their
origin/branchremote-tracking ref still has it — push from there - The perpetrator's reflog has it by definition
Prevention beats rescue: branch protection forbids force-push to shared branches (there is no legitimate reason on main); --force-with-lease instead of --force everywhere else (refuses if the remote moved since your last fetch — protects against clobbering a teammate's interleaved push).
One-liner: 'shared history only moves forward — revert is the public undo, reflog and remote-tracking refs are the rescue kit, and protection rules make the worst accidents unrepresentable.'
What are GitHub Actions / GitLab CI in one mental model, and how do the two platforms differ?
The shared mental model: event-driven pipelines defined in repo YAML — an event (push, PR, tag, schedule, manual) triggers a workflow; jobs run on runners (hosted or self-hosted); steps are shell commands or reusable units; secrets/permissions injected per run; status reported back to the commit/PR as the merge gate.
GitHub Actions specifics:
- Reuse via the Marketplace ecosystem (
uses: actions/checkout@v4) — enormous supply, and a real supply-chain surface (third-party code in your build context — pin by SHA, curate) - Reusable workflows + composite actions for org-level paved roads; OIDC federation to clouds is first-class
- Config: per-repo
.github/workflows/*.yml; org-level controls via required workflows/rulesets
GitLab CI specifics:
- One
.gitlab-ci.ymlper repo; reuse viainclude:(templates from other repos/groups) and CI/CD components — the paved road is template composition rather than a marketplace - Auto DevOps, built-in registry, environments/review-apps as first-class platform objects; runners self-managed more commonly
- The platform is a broader integrated suite (issues → CI → registry → security dashboards in one product), vs GitHub's ecosystem-of-integrations shape
The differences that actually matter in a decision: where your code already lives (gravity wins), hosted-runner economics vs self-hosted appetite, marketplace-supply-chain risk tolerance vs template-composition preference, and self-hosting maturity (GitLab self-managed is the deeper on-prem story).
One-liner: 'same model — events, YAML, runners, gates; GitHub bets on ecosystem, GitLab on integrated suite — pick where your code lives and engineer the pipeline logic to be portable anyway.'
What is .gitignore actually doing, and why doesn't adding a file to it untrack it? Plus: what belongs in a repo and what never does.
The mechanic everyone trips on: .gitignore affects only untracked files — it stops things from being added, it doesn't stop tracking things already committed. The tracked-file fix:
git rm --cached config/local.env # untrack, keep the file on disk
# now .gitignore applies to it
(And the file's history still exists — if it held a secret, ignoring it now is not remediation; see secret-leak handling.)
Layering: repo .gitignore (shared, versioned — build outputs, node_modules, .env), ~/.config/git/ignore (personal-global — your editor's swap files, .DS_Store; don't pollute every repo's ignore file with your editor choices), .git/info/exclude (local-only, unshared — repo-specific personal scratch).
What belongs in the repo: source, tests, build/pipeline definitions, lockfiles (yes — reproducibility), migrations, docs, example configs (.env.example).
What never does:
- Secrets — any credential, ever (pre-commit scanners like gitleaks as the enforcement, not intention)
- Generated artifacts (build outputs, compiled binaries — they bloat history forever; Git never forgets a committed 200MB bundle, every clone carries it)
- Large binaries as a class — Git stores snapshots; binary churn = repo obesity (Git LFS for the legitimate cases: design assets, model files — pointers in Git, content in LFS storage)
- Local/env-specific config (the
.envfile itself), IDE state, OS junk
One-liner: 'gitignore is a gate for the untracked, not an eraser for the tracked — and the repo holds what defines the software; anything derived, personal, or secret stays out because Git's memory is permanent by design.'
Tags, releases, and versioning: how do you cut and manage releases on GitHub/GitLab?
Tags — the Git primitive: a named pointer to a commit; annotated tags (git tag -a v2.4.0 -m ...) carry tagger/date/message and are the release-grade form (lightweight tags are just pointers — fine for local bookmarks). Tags don't push by default (git push origin v2.4.0), and should be immutable in practice — moving a published tag breaks everyone who pinned it (registries treat re-tagging as an incident; so should you).
Releases — the platform object on top: a tag + release notes + attached artifacts (binaries, SBOMs, checksums) + the marketing surface (latest/pre-release flags). The platform release is what humans and download links consume; the tag is what tooling consumes.
The automated pipeline (the modern standard):
- Conventional commits (
feat:,fix:,feat!:) accumulate on main - Release tooling (semantic-release, release-please, GitLab's equivalents) computes the next semver from commit types (fix→patch, feat→minor, breaking→major), generates the changelog, tags, and creates the platform release — release-cutting as a zero-human pipeline step
- The tag event triggers the release workflow: build artifacts from the tag, sign, attach, publish — tag push is the ceremony trigger, and tag protection rules ensure only release automation (or release managers) can push
v*tags - Deployment references the release artifacts by digest/checksum — the release object is the traceability hub: version → tag → commit → PRs → issues
Worth volunteering: monorepo versioning (per-package tags like pkg-a/v1.2.0), release branches only for long-term-support lines (backport via cherry-pick + patch tags), and the trap of building releases from a branch rather than the tag (the branch can move after you decided to release — the tag can't).
A secret was committed and pushed 3 weeks ago. Walk through the complete remediation — and why 'delete the file and force-push' isn't it.
The first truth: the moment a secret hits a remote, treat it as compromised — clones, forks, CI caches, platform events APIs, and scrapers (bots watch public repos in real time; private repos still have every team member's clone) all have copies. History rewriting is cleanup, not remediation.
The order of operations:
- Rotate the credential NOW — before any Git surgery. The race is between you and whoever scraped it; rewriting history first is fixing the barn door's paint while the horse bolts. Verify the old credential is dead (use it; expect failure) and audit its access logs for the 3-week window (this is an incident, possibly a breach — scope it like one)
- Then clean history (for hygiene + preventing future re-leaks via archaeology):
git filter-repo(the maintained tool; BFG as the legacy alternative —filter-branchis deprecated and slow) to excise the blob across all history → force-push all branches/tags → platform-side purge (GitHub support request / GitLab housekeeping to drop cached views and unreachable objects — the platform retains objects your force-push didn't delete, including in the events API) - Coordinate the rewrite: every collaborator must re-clone or hard-reset (their old clones re-introduce the secret on their next push otherwise); open PRs based on old history need rebasing; forks are out of your control — which is why step 1 is the only real fix
- Post-incident hardening: pre-commit + CI secret scanning (gitleaks/platform-native push protection — GitHub's blocks the push before it lands, the only version that actually prevents), secrets moved to a manager with short TTLs (a leaked 15-minute token is a non-event), and the retro question: why did a human have a long-lived secret to paste?
Why the naive fix fails, explicitly: deleting the file in a new commit leaves the secret in every prior commit (git log -p finds it in seconds); even force-pushed rewrites leave platform-cached objects and everyone's clones; and none of it touches the copies already exfiltrated during the 3-week window.
One-liner: 'rotate first, rewrite second, re-clone third, harden fourth — Git surgery is hygiene; only rotation is remediation, and push-time scanning is the only prevention that isn't hope.'
Design branch protection and repository governance for a 300-repo organization — the ruleset that scales without a bureaucracy.
The failure modes to design against: 300 repos with hand-configured settings = drift (the repo that somehow allows force-push to main), and the opposite failure — a central-committee bottleneck where every repo change needs platform-team tickets.
The layered design:
- Org-level rulesets (GitHub) / group-level settings (GitLab) as the floor: applied by pattern to all repos, not configurable away by repo admins:
- Default branch: no direct pushes, no force-push, no deletion; PRs required with ≥1 review; required status checks (the CI contract); signed commits where the compliance tier demands
- Tag protection on
v*(release automation only) - These are the non-negotiables — deliberately few
- Tier the repos, don't uniform them: production-service repos (strict: 2 reviews on sensitive paths, required checks, linear history), library repos (strict + release protections), sandbox/experimental (minimal — heavy governance on scratch repos teaches people to work outside the platform). Tier assignment via repo topics/custom properties driving ruleset targeting
- CODEOWNERS as distributed authority: sensitive paths (auth/, payments/, .github/workflows/ — pipeline changes are production changes!) require owner review; ownership lives with teams, not the platform org. Audit for staleness — CODEOWNERS pointing at departed employees is silent protection decay
- Config-as-code for the config: repo settings themselves managed via Terraform (github/gitlab providers) or a settings-sync app — repo creation is a PR to the repos module (template applied, tier set, owners declared), drift detection catches console cowboys, and the auditor's 'show me all repo protections' is a git file, not 300 screenshots
- The escape valve that prevents shadow IT: self-service exceptions with expiry + visibility (a labeled PR to the settings repo) — a governed way to loosen a rule beats an ungoverned workaround culture. Metrics on exceptions drive rule tuning: a rule with 40 standing exceptions is a wrong rule
- Merge queues on the hot repos (high merge velocity + required checks = the up-to-date-branch death spiral without one)
One-liner: 'a small set of org-level non-negotiables, tiered strictness by repo class, CODEOWNERS for distributed authority, all managed as code with a governed exception path — protection that scales is protection nobody has to remember to apply.'
Monorepo vs polyrepo at organization scale: the real trade-offs, the tooling bill, and how you'd decide.
The honest framing: you're choosing which problems to have.
Monorepo problems-you-buy: tooling investment is mandatory — at scale you need: build-graph tooling (Bazel/Nx/Turborepo) for affected-only CI, sparse/partial clone (a 10GB repo kills laptops and CI without it), merge queues (one hot main), path-based CODEOWNERS discipline, and eventually dedicated repo-performance work (Git itself strains: git status latency, index size — Microsoft built VFS/Scalar for exactly this). The platform team is a line item.
Monorepo problems-you-escape: atomic cross-cutting changes (rename an API + all 200 callers in one reviewed PR), no dependency-version matrix between internal packages (everything builds against HEAD — the diamond-dependency problem dissolves), universal code search/refactoring, one CI convention, no 'which repo does X live in' archaeology.
Polyrepo problems-you-buy: cross-cutting changes become N-repo campaigns (deprecation cycles measured in quarters; automation like multi-repo PR bots is mandatory muscle), internal package versioning + the coordination cost of every shared-library bump (consumer lag, version skew in production), discovery/consistency drift (40 repos = 40 slightly different CI configs unless templates are enforced), and dependency graphs between repos that no tool fully sees.
Polyrepo problems-you-escape: repo-level ownership/permissions are trivially clean (repo = team = access boundary), CI blast radius naturally scoped, no giant-repo performance engineering, teams evolve independently (including tooling choices — for better and worse).
The decision inputs that actually matter:
- Coupling reality: if your services genuinely share lots of code/contracts and change together, monorepo matches reality; if domains are truly independent (different products, compliance boundaries, acquisition integrations), polyrepo matches
- Platform staffing honesty: a monorepo without a funded platform team degrades into everyone's-problem; polyrepo without automation muscle degrades into version-skew chaos — pick the failure mode you can staff against
- Compliance/access hard lines: need-to-know isolation (regulated code, client IP separation) is awkward in monorepos (path permissions are weaker than repo permissions)
The hybrid most large orgs actually land on: a monorepo per platform/product domain (where the atomic-change benefit is real) + separate repos for genuinely independent systems — 'monorepo where coupled, polyrepo where not', plus org-wide templates so polyrepo drift stays bounded.
One-liner: 'monorepos trade tooling investment for atomic changes and dissolved version skew; polyrepos trade coordination campaigns for clean boundaries — decide by measuring how often changes want to cross repo lines, and staff the platform for whichever pain you pick.'
GitHub Actions security: the pwn-request problem, third-party action supply chain, and hardening an org's workflows.
The threat model people miss: CI workflows are remote code execution wired to your secrets and your cloud — and PRs are untrusted input to them.
The marquee vulnerability class — pull_request_target pwn requests: pull_request from forks runs with read-only token and no secrets (safe by design); pull_request_target runs in the base repo's context (secrets available!) — combine it with checking out the PR's head code and executing anything (install scripts, tests) and an attacker's fork PR exfiltrates your secrets. This exact pattern has burned major projects repeatedly. Rule: pull_request_target only for workflows that never execute PR-controlled code (labelers, comment bots), enforced by review on workflow changes (CODEOWNERS on .github/workflows/ — workflow edits are production changes).
Third-party action supply chain: uses: someuser/action@v3 executes their code in your context, and tags are mutable — a compromised maintainer re-points v3 and every consumer runs malware on the next build (this is not hypothetical — tj-actions/changed-files 2025 was exactly this).
- Pin by full commit SHA (
@a1b2c3...with a version comment), org-level allowlist (GitHub's policy setting: only allowlisted/verified actions), dependabot/renovate to move pins deliberately, and prefer first-party + a small curated internal action set over marketplace sprawl
The hardening checklist beyond those two:
- Default token permissions read-only (org setting) — workflows request write scopes explicitly per job (
permissions:block); the default GITHUB_TOKEN with org-wide write is lateral movement waiting - OIDC to clouds, zero stored cloud keys — federated short-lived credentials with subject claims scoped to repo+branch+environment ('only main of repo X deploying via environment prod can assume this role')
- Environments for deployment secrets — prod secrets bound to protected environments (required reviewers, branch restrictions) — a compromised PR workflow can't reach them
- Self-hosted runner isolation: ephemeral runners (fresh VM/container per job — persistent runners accumulate credentials and let jobs poison successors), never shared between public-repo and internal workloads, egress-restricted
- Script injection hygiene:
${{ github.event.pull_request.title }}interpolated intorun:is shell injection by PR title — untrusted event data goes through env vars, never inline interpolation - Audit: workflow-run logs to SIEM, alerts on new secrets access patterns, org-level required workflows for the security baseline
One-liner: 'treat workflows as prod services taking hostile input: SHA-pin and allowlist third-party code, never mix pull_request_target with PR code execution, read-only tokens and OIDC by default, prod secrets behind environments, ephemeral runners — and CODEOWNER the workflows directory because that's where the keys are wired.'
Your repo has grown to 8GB and clones take 20 minutes. Diagnose and fix — shallow clone, partial clone, LFS, and history surgery.
Diagnose first — where's the weight? git count-objects -vH (total size), then the real question: what's in the pack? — git rev-list --objects --all | git cat-file --batch-check sorted by size (or git-sizer, purpose-built) reveals the distribution: a few huge blobs (someone committed datasets/binaries), churned medium binaries (a 20MB asset updated 400 times = 8GB of history), or genuinely huge tree/history (monorepo scale).
The fix menu by root cause:
- Consumers first (no repo surgery, works today):
- Shallow clone (
--depth 1): CI's answer — most builds need HEAD, not history. Caveats: some tooling wants history (version-from-git, changelog generation — fetch more selectively where needed) - Partial clone (
--filter=blob:none): full history structure, blobs fetched on demand — the better default for developers (log/blame work; checkout fetches what it touches). Combined with sparse-checkout for monorepo subsets (only the directories you work on materialize) - CI reference caches / mirror-based clones on runners — clone from a local reference, seconds not minutes
- Shallow clone (
- Stop the bleeding (policy): pre-receive/push rules blocking files >N MB (platform settings), CI checks on PR diffs, and LFS onboarding for the legitimate binary classes (models, assets) — LFS moves content out of the pack (pointers in Git, blobs in LFS storage, fetched for checkout only). Know LFS's costs honestly: server/storage dependency, egress bills on busy repos, and workflow friction (forgotten
git lfs installproduces pointer-file confusion) - History surgery (the rewrite decision):
git filter-repoto excise the giant blobs / migrate historical binaries to LFS retroactively — shrinks the pack permanently but rewrites all SHAs: coordinated like the secret-leak drill (all collaborators re-clone, open PRs rebased, external SHA references — deploy pins, submodule refs, issue links — break). Worth it when the weight is historical junk; not worth it when the weight is legitimate ongoing content (fix that with LFS + partial clone instead) - If it's genuinely monorepo-scale history: don't fight it with rewrites — Scalar/partial-clone/fsmonitor tooling is the Microsoft-proven path; the repo is big because the org is big
Result shape: typical outcome — CI clones 20min→40s (shallow+reference), dev clones →2min (partial+sparse), repo growth curve flattened by push rules + LFS, and the surgery decision made once with eyes open rather than annually by frustration.
One-liner: 'measure what's heavy before choosing — shallow/partial clone fixes the experience today, push rules and LFS fix the trajectory, and history rewrite fixes the past at coordination cost; most orgs need the first two and only sometimes the third.'
Explain merge queues: the problem they solve, how they work, and when a team actually needs one.
The problem — 'required up-to-date' at velocity: branch protection demands PRs be current with main so CI results mean something. At low velocity, fine. At 50 merges/day: you update your branch, CI runs 15 min, someone merges before you, you're stale again — the retest treadmill, where merge latency grows super-linearly with team size and people start batching changes (making everything worse) or admins start bypassing checks (making everything meaningless).
The subtler bug it also fixes: two PRs, each green independently against an older main, can be semantically incompatible (A renames a function, B adds a caller of the old name) — both merge, main is broken though every check passed. Testing against the merge result including concurrent merges is the only honest gate.
How it works: approved PRs enter a queue; the platform constructs speculative merge commits — PR#1 on main, PR#2 on (main+PR#1), PR#3 on (main+PR#1+PR#2) — and runs CI on each in parallel (optimistic batching). All green → fast-forward main through the batch. A failure → the failing PR is ejected, the queue behind it is rebuilt without it (its speculative bases were invalidated) and re-tested. Throughput ≈ parallel-tested batches instead of serialized retest cycles.
The operational realities:
- CI time is now the merge heartbeat — a 40-minute suite caps queue throughput regardless of parallelism (deep queues + one failure = expensive rebuilds); merge queues create direct pressure to get the queue-gating suite under ~15 min (tiering: queue runs the essential set, post-merge runs the depth)
- Flaky tests become queue poison — one flake ejects an innocent PR and rebuilds the queue behind it; the flake-quarantine machinery stops being optional
- Queue-jumping policy for emergencies (hotfix priority lanes), and metrics: time-in-queue, ejection rate, rebuild cost — the queue is a system to operate, not a checkbox
When you actually need one: the signal is merge-velocity friction — engineers complaining about update-retest loops, >~15-20 merges/day on a protected branch, or any semantic-conflict incident on a 'all green' main. Below that traffic, required-up-to-date + discipline suffices; above it, the queue pays for itself in days. (GitHub merge queue, GitLab merge trains — same concept.)
One-liner: 'a merge queue tests each PR against the future main it will actually land on, in parallel batches — it converts the retest treadmill into a pipeline, at the price of making CI speed and flake hygiene everyone's urgent problem.'
Git internals under pressure: explain how git bisect, cherry-pick, and worktrees actually work, and give the production use case for each.
git bisect — binary search over history: you mark a bad commit and a known-good one; Git checks out the midpoint, you test (or it does — git bisect run ./test.sh), and it halves the range each round: a regression hiding in 4,000 commits found in ~12 tests.
- The production pattern: automated bisect in CI — a nightly perf regression appears; a job runs
bisect runwith the benchmark script and posts the exact commit to the channel before humans wake up. Requirements it imposes upstream: every commit on main builds and tests (squash-merge discipline pays off here — bisecting through broken WIP commits is misery) and the test script exits 0/1 honestly (125 for 'skip this commit') - The subtlety: bisect works on any monotonic property — 'when did the bundle exceed 5MB', 'when did this log line appear' — not just test failures
cherry-pick — replay a commit's diff elsewhere: creates a new commit (new SHA, same change) on the current branch.
- The production use case: release-branch backports — fix lands on main,
cherry-pick -x <sha>onto release-1.4 (the-xstamps provenance: 'cherry picked from commit...' — your audit trail across branches). At scale this is automated (label a PRbackport-1.4, a bot cherry-picks and opens the PR — GitLab/GitHub bots standard) - The honesty: cherry-picked commits are different commits — Git can't dedupe them at future merges perfectly (rebase handles it via patch-id, merges can produce duplicate-change conflicts); heavy cherry-pick flows between long-lived branches accumulate friction, which is an argument for trunk-based + short release branches, not against backports
git worktree — multiple working directories, one repo: git worktree add ../hotfix release-1.4 — a second checkout sharing the same object database (no re-clone, no stash dance).
- The production use cases: the mid-feature production hotfix (your feature branch stays untouched with its dirty state; the hotfix happens in a parallel worktree), long-running local builds against one branch while developing on another, and AI-agent/parallel-experiment workflows (N agents, N worktrees, zero interference — one object store)
- Mechanics worth knowing: each worktree has its own HEAD/index; a branch can be checked out in only one worktree at a time;
worktree prunefor cleanup
One-liner: 'bisect turns regressions into O(log n) searches — if your commits are honest; cherry-pick moves diffs across branches with provenance — the backbone of backport automation; worktrees give parallel checkouts off one object store — the end of the stash-and-switch dance.'
Design the code review culture and mechanics for a 60-engineer org: SLAs, review quality, and what automation should own.
The core stance: review is a throughput system with a quality function — design both, measure both.
Mechanics first (structure shapes culture):
- Size discipline as policy: PR size guidance (<400 lines changed) with dashboards — review quality falls off a cliff with size (defect-detection studies are unambiguous); big changes decompose into stacked/chained PRs (tooling: Graphite-style stacks or disciplined chains). The single highest-leverage review intervention is smaller PRs
- Turnaround SLA: first response <4 business hours, and visible (per-team review-latency dashboards) — slow review is the #1 hidden throughput killer and the #1 driver of oversized batching (why open small PRs if each waits two days?). Review time is scheduled work (calendar blocks, review-first mornings), not interstitial charity
- Routing that spreads load and knowledge: CODEOWNERS for authority on sensitive paths + round-robin/load-aware assignment for the rest — the 'everyone asks the one senior' pattern burns the senior and starves everyone else's growth. Two-reviewer requirements only where risk justifies (auth, payments, migrations, workflows)
- Author obligations codified: description states problem/approach/testing; self-review pass before requesting (annotate the non-obvious); respond to every thread — the PR is the author's product, reviewers are customers
What automation must own (humans review what only humans can): formatting/style (formatters + linters — a human commenting on indentation is a process failure), obvious bug classes (static analysis, type checks), coverage deltas, security patterns (scanners), dependency risks (bots) — CI comments before human eyes arrive. Review humans for: design fit, naming/clarity, edge cases, operational consequences ('what pages when this breaks?'), and knowledge transfer.
The culture mechanics (harder than tooling):
- Review comments are questions/offers, not verdicts ('what happens if X is nil here?' beats 'this is wrong'); severity labels (blocking vs nit vs FYI) so authors know what gates. Approve-with-nits as the default for non-blocking feedback — perfectionism gates are velocity theater
- Disagreement escalation path: two rounds unresolved → synchronous conversation → team lead decides — threads that run 15 comments deep are a process smell
- Review as mentorship made explicit: juniors review seniors' PRs too (with 'ask anything unclear' license) — reading expert code with the right to interrogate it is the fastest growth loop, and it distributes bus-factor
Metrics with self-awareness: track review latency, PR size, time-to-merge (system health) — never 'comments per review' or approval counts (instantly gamed, culturally corrosive). Quarterly: sample-audit merged PRs — did review catch what it should have? Escaped-defect retros feed back into what automation should catch next.
One-liner: 'small PRs, fast first response, automated everything automatable, human attention on design and consequences, disagreements escalated not litigated — review is the org's main knowledge-transfer and quality system; treat its latency and its kindness as production metrics.'
GitLab/GitHub as a production system: rate limits, webhook reliability, and building platform automation that doesn't fall over.
The mindset shift: at platform-engineering scale, GitHub/GitLab is a critical external dependency with SLAs, rate limits, and outages — your deploy pipeline, your merge flow, and half your automation die when it hiccups. Engineer accordingly.
Rate-limit engineering:
- Know the budgets: REST (5K/hr per authenticated principal on GitHub; GraphQL separately point-costed), secondary limits (burst/concurrency — the ones that surprise), per-installation app limits (higher, and the reason serious automation runs as a GitHub App, not a PAT-wielding bot user)
- Patterns that survive: conditional requests (ETags — 304s are free on GitHub's budget), webhook-driven state instead of polling (the #1 rate-limit sin is polling for what webhooks push), GraphQL for fan-out reads (one query replacing 50 REST calls), caching with honest TTLs, exponential backoff honoring
Retry-After, and per-consumer token budgeting so one runaway script doesn't starve the deploy bot - The failure story: rate-limited deploy automation during an incident is a compound incident — reserve capacity (dedicated app installation for the critical path) and alert on consumption trends, not just exhaustion
Webhook reliability (the delivery is at-least-once-ish, your handler must be better):
- Verify signatures (HMAC — an unauthenticated webhook endpoint is a remote-trigger for your automation), respond fast (ack then process async — queue-backed handlers; platforms time out slow receivers and mark deliveries failed), idempotent processing (delivery IDs deduped — retries and duplicates are contractual)
- Missed events are a when, not an if (receiver down, platform blip, redelivery window expired): every webhook-driven system needs a reconciliation loop — periodic list-based resync (cheap with conditional requests) that converges state the events missed. Event-driven for latency, reconciliation for correctness — the same pattern as every control plane
- Operational hygiene: webhook delivery logs monitored (platforms expose recent deliveries + redelivery buttons/APIs), dead-letter queues on the processing side, and replay tooling for backfilling gaps
Platform-outage posture: deploys must have a break-glass path that doesn't transit the SCM platform (artifacts already in your registry + GitOps agent already holding desired state = in-flight deploys survive; new merges wait, and that's an accepted, documented stance), status-page integration into your own incident tooling, and periodic game-days that unplug GitHub and verify the blast radius matches the doc.
One-liner: 'treat the SCM platform like any tier-1 dependency: apps not PATs, webhooks not polling, idempotent handlers backed by reconciliation, reserved API budget for the deploy path, and a rehearsed answer for the day it's down.'
Submodules vs subtrees vs vendoring vs package registries: sharing code between repos — what actually works?
The problem: repo A needs code from repo B. The four answers, with the scar tissue attached:
- Submodules — a pointer (commit SHA) to another repo embedded in yours:
- The scars: the detached-HEAD-inside-submodule confusion, forgotten
--recurse-submodulesproducing empty directories, submodule bumps as noisy unreviewable diffs (Subproject commit abc→def), and CI/tooling that half-supports them. They pin precisely (good) but every consumer must manage the pointer dance (bad) - The legitimate uses: vendoring a repo you must build from source at an exact ref (firmware components, a fork you patch), and cases demanding provable source-level pinning. Rule: fine at the edges of your build, painful woven through a team workflow
- The scars: the detached-HEAD-inside-submodule confusion, forgotten
- Subtree — the other repo's content merged into yours (with history, optionally squashed): consumers see plain files — zero workflow tax downstream; the integration cost lands on whoever runs
subtree pull/push(awkward, manual, easy to drift). Good for absorb-and-diverge (you're effectively forking inward); weak for staying-in-sync relationships - Vendoring (copy the source in, tool-managed —
go mod vendorstyle): hermetic builds, no fetch-time dependencies, diffs visible in review. The cost: repo weight and update discipline (automation must refresh it, or it fossilizes). Strong for supply-chain-paranoid and air-gapped builds; Go normalized it for a reason - Package registries (the default answer): B publishes versioned artifacts (npm/Maven/PyPI/Go modules/crates via Artifactory or platform registries); A declares a dependency with semver + lockfile:
- This is the designed mechanism: versioned contracts, resolvable graphs, deprecation cycles, security scanning hooks, renovate-driven updates as reviewable PRs
- The tax: release discipline required from B (versioning, changelogs, CI publishing) — which is a feature: it forces the interface to be owned
The decision rule: shared libraries → registry, always (the other three are workarounds wearing use cases). Build-from-source-at-exact-ref requirements → submodule at the edge. Absorbing something you'll diverge from → subtree/fork. Hermetic/air-gapped constraints → vendoring, tool-managed.
The org-level move that dissolves most of the question: internal registries + a paved-road publish pipeline (any repo can ship a versioned package in an afternoon) — teams reach for submodules mostly where publishing is harder than pointing; fix the publishing friction and the right answer becomes the easy one.
One-liner: 'registries are the designed answer — semver contracts and lockfiles; submodules pin source at the edges, subtrees absorb, vendoring hermeticizes — and every submodule woven through a daily workflow is a publishing pipeline someone didn't build.'
You inherit a repo where main is broken weekly despite required CI. Diagnose the systemic causes and fix the delivery pipeline's integrity.
The investigation (each cause has a distinct signature):
- Semantic conflicts between concurrently-green PRs — signature: individual PR checks passed, the combination broke. Fix: merge queue (test against the actual future main). At >15-20 merges/day this is almost certainly a top cause
- Stale-branch merges — required-up-to-date disabled (because of the retest treadmill — see: why queues exist) means PRs tested against week-old main. Same fix
- Check-requirement gaps — 'required CI' that isn't: checks marked required but skippable via path filters that skip too much (the workflow YAML change that doesn't trigger the workflow it edits!), admin bypass being used routinely (audit the bypass log — this is a 30-second query with a culturally loud answer), draft-to-ready races, or required checks that report on push but not on the merge commit
- Flaky tests trained into noise — teams retry-until-green; genuinely broken code eventually passes on a lucky roll. Signature: the breaking commit's CI shows retries. Fix: the flake-quarantine machinery + retries made visible-and-attributed, never silent
- Post-merge-only failures — the PR tier is too thin (integration/E2E only on main): breakage is discovered post-merge, not caused there. Fix: rebalance tiers — anything that regularly catches breaks post-merge belongs (in scoped form) pre-merge
- Out-of-band commits — automation/bots/humans pushing directly (release bots, changelog bots with push rights) — audit non-PR commits on main; bots follow the same PR path or their pushes are the hole
The integrity redesign (in rollout order):
- Week 1: audit the bypass and non-PR commit logs (facts before process), turn off admin-merge-when-red except break-glass-with-postmortem, fix the check-requirement gaps (required checks on merge commits, workflow-file changes always trigger)
- Week 2-3: merge queue on the hot repos + queue-tier CI trimmed to <15 min (move depth post-merge — but see #5: keep the classes that were escaping)
- Parallel: flake program (quarantine lane, visible retries, fix-SLA) — queues make this mandatory anyway
- Cultural ratchet: main-is-red = stop-the-line (merges pause until green — the queue enforces this mechanically), revert-first policy (revert in minutes, investigate offline — main's health outranks anyone's in-flight narrative), and a weekly 'main broke' counter on the engineering dashboard — the metric that keeps the fixes funded
Result shape: weekly breaks → quarterly, MTTR-on-red from hours (debate) to minutes (revert-first), and — the compounding win — engineers trust green again, which un-batches changes and speeds everything downstream.
One-liner: 'main breaking despite required CI means the requirements have holes — audit bypasses and out-of-band pushes, test the merged future not the stale branch (queue), un-train the retry culture, and make revert-first + stop-the-line the reflex; integrity is a system property, not a checkbox.'
Git hooks, husky-style tooling, and server-side hooks: what belongs where in the enforcement stack?
The three enforcement tiers — and the golden rule that client-side is convenience, server-side is control:
- Client-side hooks (pre-commit, commit-msg, pre-push) — via husky/pre-commit/lefthook, versioned in the repo:
- What belongs: fast feedback that saves round-trips — formatters, linters on staged files, commit-message conventions, secret scanning (catching the key before it's in history is categorically better than after), quick unit subsets on pre-push
- The constraints: trivially bypassable (
--no-verify, or just an unconfigured clone) — never the enforcement of record; and the speed budget is sacred — a 90-second pre-commit hook trains the whole team onto--no-verifypermanently (sub-5s for pre-commit; pre-push can afford ~30s). Staged-files-only scoping (lint-staged pattern) is what keeps them fast
- Server-side hooks (pre-receive/update) — GitLab push rules, GitHub rulesets/pre-receive (enterprise):
- What belongs: the actual guarantees — blocked force-pushes, file-size limits, secret-pattern rejection (GitHub push protection — the server-side version is the one that prevents), commit signature requirements, ref-name policies
- These run on the platform, unbypassable by client config — this tier is where 'policy' means policy
- CI status checks (the third tier people forget is enforcement): anything too slow or too contextual for hooks — full lint/test/scan suites as required checks; the PR gate is effectively a server-side hook with a UI
The design pattern that ties them together — same checks, three speeds: one configuration (the linter config, the secret patterns, the commit convention) consumed by all three tiers — pre-commit runs it on staged files (seconds), CI runs it on the diff (minutes), server rules enforce the non-negotiables (always). Drift between tiers (hook passes, CI fails) is developer-trust poison; single-source the configs.
The rollout wisdom: hooks auto-install via the repo's bootstrap (npm prepare/mise/devcontainer — an unconfigured clone should be hard to achieve accidentally), org-template the standard set, and measure bypass rates where visible (commits landing that fail the hook-checks = people are bypassing = the hooks are too slow or too noisy — fix the hooks, not the people).
One-liner: 'client hooks buy fast feedback, server rules buy guarantees, CI buys depth — single-source the checks across all three, keep the client tier under five seconds, and never confuse a bypassable convenience with an enforcement boundary.'
Signed commits and verified provenance: GPG/SSH/gitsign, what verification actually proves, and rolling it out without developer revolt.
What the threat actually is: Git author/committer fields are freeform strings — git config user.email ceo@corp.com and every commit you make impersonates the CEO. On its own that's noise; combined with automation that trusts authorship (auto-approvals, CODEOWNERS satisfaction, deploy triggers keyed on committer) it's an attack path. Signing binds commits to a cryptographic identity.
The signing options, practically:
- GPG — the classic: powerful, and the UX that made 'signed commits' a groan (key generation ceremonies, keyservers, expiry surprises, agent configuration per machine)
- SSH signing (git 2.34+) — sign with the SSH key you already have (
gpg.format ssh): 90% of the value at 10% of the friction; platform-verified. This is the pragmatic default for humans now - gitsign / Sigstore keyless — sign with your OIDC identity (the SSO login), certificates ephemeral, transparency-logged: no key custody at all; the natural fit for orgs already doing keyless artifact signing — one identity model from commit to container
What a verified badge actually proves (and doesn't): that the commit was created by someone controlling that key/identity at signing time. It does not prove the code is safe, the author wasn't coerced/compromised, or — the big one — anything about merge commits and squashes: platform-side merges (squash, rebase-merge, web edits) are signed by the platform's key, not the author's. A repo of squash-merged PRs shows GitHub's signature on every main commit — which changes the claim from 'author signed' to 'the platform attests it merged this reviewed PR'. Decide which claim your compliance story needs before mandating.
The rollout that avoids revolt:
- Make it zero-thought first, mandatory second: paved-road setup (dotfiles/mise/laptop-provisioning does the config; SSH-signing means no new keys), verify-in-CI in observe mode building the adoption dashboard, THEN branch-protection enforcement (require signed commits) repo-tier by repo-tier
- Solve the bots simultaneously — CI/release/renovate commits need signing identities too (App-signed commits / dedicated bot keys / gitsign with workload identity); a mandate that breaks the release bot on day one dies by rollback
- Vigilant-mode/strictness settings so unsigned history is visibly flagged rather than retroactively rejected (history is unsigned; that's fine — the ratchet applies forward)
- Be honest about the value tier: for most orgs, signed commits are one layer — the stronger provenance claims live in the artifact chain (signed builds attesting which commit they built, admission verifying it) where the SLSA machinery operates; commit signing feeds that chain its first link
One-liner: 'unsigned Git identity is a config-file claim; SSH/keyless signing makes it cryptographic at near-zero friction — roll it out paved-road-first, sign your bots, know that squash-merges shift the attestation to the platform, and treat it as the first link of the provenance chain rather than the whole story.'
Automate dependency updates at scale: renovate/dependabot across 300 repos without drowning teams in PR noise.
The failure mode both directions: no automation = quietly rotting dependencies (the median unpatched critical is discovered at incident time); naive automation = 40 PRs per repo per week, teams auto-ignore the bot, and the important update drowns in patch noise. The design goal is high signal, low ceremony, fleet-wide consistency.
The architecture (Renovate as the assumed tool — dependabot for the lighter variant):
- Central config, inherited: an org preset repo (
renovate-config) that all repos extend — one place defines schedules, grouping, and policy; repo-level overrides are the exception. Config drift across 300 repos is the first thing to prevent - Noise engineering (the core craft):
- Grouping: monorepo-aware groups (all
@aws-sdk/*as one PR), ecosystem groups (all patch-level devDependencies weekly as one PR) — 40 PRs become 4 - Scheduling: non-urgent updates batched to a weekly window (Monday morning PRs, reviewed with coffee); security updates exempt from schedule — they land immediately
- Automerge tiers: patch + dev-dependency updates with green CI automerge (this is where trust in your test suite becomes a dependency-management feature); minors automerge for mature repos that opt in; majors always human-reviewed with the changelog surfaced in the PR body
- Grouping: monorepo-aware groups (all
- Security-first lanes: vulnerability-driven updates (Renovate's vulnerability alerts / dependabot security PRs) bypass all batching, labeled and paged-adjacent for criticals — patch-Tuesday cadence for hygiene, immediate for CVEs, and the SBOM/scanner integration closing the loop (is the vulnerable version actually gone from deployed artifacts?)
- The lockfile-maintenance and pin strategy decisions: apps pin exact versions (lockfile is the truth, updates are explicit PRs); libraries use ranges (their consumers resolve) — Renovate's range strategy configured accordingly; scheduled lockfile-maintenance PRs keep transitive freshness without version-range churn
- Internal packages get the same machinery: your own libraries flow through the same update PRs (internal registry + renovate) — the shared-library rollout problem (one fix, 200 consumers) becomes 'merge the bot PRs', with the library team watching adoption dashboards instead of begging in Slack
The metrics that prove it works: median dependency age (trending down), time-from-CVE-to-fleet-patched (the number security asks about), automerge rate (rising = trust in tests rising), and bot-PR close-without-merge rate (rising = noise returning — retune).
The cultural piece: the bot's PRs are real PRs — teams that treat them as spam get the rotting-dependency incident eventually; the platform's job is making the signal honest enough that ignoring it is visibly a choice, and the paved-road repos (good tests + automerge) make freshness free enough that most teams take it.
One-liner: 'central config, grouped and scheduled updates, automerge where tests earn it, security lanes that skip the queue, and internal packages on the same rails — dependency automation succeeds exactly to the degree it respects the reviewer's attention budget.'
GitOps repository design: mono-env-repo vs repo-per-env, directory layouts, and the promotion model — design it for 30 services × 4 environments.
First, the anti-pattern to name: app code and deployment manifests in the same repo tangles cadences (a values tweak triggers app CI; an app change can't merge without manifest review) and makes the deploy audit trail noisy — separate app repos (code → images) from environment/deploy repos (desired state → clusters). The interview question is really about structuring the latter.
The layout decision for 30 services × 4 envs:
- One environments repo, directory-per-env (my default):
deploy-repo/
├── base/payments/ # shared kustomize base / chart values
├── envs/
│ ├── dev/payments/values.yaml
│ ├── staging/payments/values.yaml
│ └── prod/payments/values.yaml # protected by CODEOWNERS
- Promotion = PR copying a digest pin from staging/ to prod/ — the diff is the promotion review, history is the deploy ledger, and cross-env consistency is greppable in one place
- Access control via CODEOWNERS on
envs/prod/**(platform/release approvers) — path-based, workable at this scale
- Repo-per-env (deploy-dev, deploy-prod): hard permission boundaries (prod repo access is a different set of humans — some compliance regimes require exactly this), independent webhook/automation blast radius — at the cost of promotion becoming cross-repo automation (a bot PRing from one repo to another) and consistency drift between repo structures. Choose when regulators/segregation demands beat ergonomic promotion
- Repo-per-team hybrid at bigger scale (30 services is fine in one; 300 services/40 teams wants per-team deploy repos + a platform repo for shared infra — ownership boundaries again)
The promotion model mechanics:
- CI (app repo) builds image → writes the digest to
envs/dev/automatically (auto-merge) → soak/gates → promotion bot opens the staging PR (auto or on-command) → human-or-gate merges → same to prod with the stricter CODEOWNERS. Digests only, never tags — the artifact that soaked is the artifact that ships - Environment parity guardrail: CI on the deploy repo diffs env directories structurally — prod containing keys staging doesn't (shape drift, not scale drift) fails the check; the drift class that causes 'works in staging' dies in review
- Rollback = revert the promotion PR (and the CD tool converges) — measured in minutes, auditable forever
The operational details that separate real designs: ApplicationSets/Flux Kustomizations generate per-env apps from the directory structure (no hand-registered apps drifting), sealed refs (each env pins chart/base versions — a base change promotes through envs too, not blast-radius-everything), bot PRs signed and rate-limited (the deploy repo's git history is production's audit log — protect it like prod), and renovate on the deploy repo for chart/base version currency.
One-liner: 'separate code repos from state repos; one env-directory repo with CODEOWNERS on prod for most orgs, repo-per-env when compliance draws hard lines — and promotion as a digest-copying PR, so every deploy is a reviewed one-line diff and every rollback is a revert.'
Handle a contentious force-push/history-rewrite need: the repo must rewrite history (secret purge / LFS migration / author scrub) with 80 active developers and 200 open PRs. Orchestrate it.
The stakes stated plainly: a history rewrite changes every SHA from the rewrite point forward — every clone diverges, every open PR's base evaporates, every external SHA reference (deploy pins, submodule pointers, issue links, build provenance) dangles. With 80 devs and 200 open PRs, an uncoordinated rewrite is a week of org-wide confusion. This is an operation, with a runbook, a window, and a rollback.
The orchestration:
- Pre-work (week before):
- Dry-run the rewrite (
git filter-repoon a clone) → validate the result (secret gone / LFS pointers correct / sizes right) and produce the old→new SHA map (filter-repo emits this — it's the key artifact for everything downstream) - Inventory SHA-references: deploy systems pinning commits, submodule consumers, release tags (must be re-pointed), CI configs, provenance/attestation stores — each gets a migration owner
- Shrink the PR problem: merge-or-close campaign on the 200 open PRs (most are stale — a 2-week 'merge it or lose the base' notice does wonders; genuinely active ones get flagged for the rebase choreography)
- Dry-run the rewrite (
- Communication as a first-class deliverable: the announcement (what/why/when), a one-page migration guide (per-situation: clean clone? uncommitted work? in-flight branch? — exact commands each), office-hours window, and a canary team that walks the guide before the org does
- The window (do it fast, do it once):
- Freeze: repo to read-only (branch protection lock / maintenance mode), final backup (full mirror clone — the rollback is 'push the mirror back')
- Execute: filter-repo → force-push all refs → re-point release tags via the SHA map → platform housekeeping (support ticket / GC to purge cached old objects — mandatory for the secret-purge case)
- Verify: fresh clone checks (content, sizes, tags), CI green on the new main, deploy-pin migrations applied
- Unfreeze with the migration guide pinned everywhere
- The developer migration (the human blast radius): in-flight branches rebase via
git rebase --ontousing the SHA map (the guide's core recipe — scriptable: a helper that maps old base → new base mechanically); uncommitted work is untouched (it's not history); anyone confused gets the nuclear-simple path: fresh clone, cherry-pick your diff across (git diff old-branch > patch→ apply on new clone). Expect and staff for 2-3 days of assistance traffic - The aftermath ratchet: whatever caused the rewrite gets its prevention installed in the same change window (push-protection secret scanning / LFS + size limits / the metadata policy) — a rewrite without the ratchet is an annual tradition waiting to happen
Rollback honesty: before anyone pushes post-rewrite work, rollback = restore the mirror; after real work lands on new history, rollback cost climbs steeply — hence the verification gate inside the window, before unfreeze.
One-liner: 'a history rewrite is a migration with a freeze window: dry-run and SHA-map first, drain the PR queue, communicate like an outage, execute-verify-unfreeze in hours not days, hand every developer an exact recipe — and install the prevention before the unfreeze so you never do it twice.'
Compare GitHub and GitLab as *platform investments* for a 500-engineer company: beyond CI — governance, compliance, self-hosting, and exit costs.
Frame it as a platform decision, not a feature checklist — at 500 engineers you're buying: an identity/governance surface, a compliance evidence system, an automation substrate, and a multi-year lock-in with real exit costs.
Governance & access:
- GitLab: hierarchical groups/subgroups with inherited settings/permissions — org modeling is native (company → division → team → project); self-managed gets instance-wide policy. Compliance frameworks/pipelines as first-class features (label a project PCI → mandated pipeline + settings)
- GitHub: org/team model flatter (enterprise → orgs → teams); rulesets + custom properties have closed much of the gap; EMU (Enterprise Managed Users) for full identity control vs the classic 'personal accounts join orgs' model — the identity philosophy difference matters to some compliance shops
Compliance evidence: both produce it; GitLab's integrated stance (audit events, compliance dashboards, security dashboards in-product) vs GitHub's ecosystem stance (API + partner/SIEM assembly). Regulated-industry self-hosting: GitLab self-managed is the deeper story (air-gapped installs are a supported reality); GitHub Enterprise Server exists but trails and the center of gravity is cloud.
The automation substrate: GitHub's ecosystem is the moat — Actions marketplace, Apps ecosystem, Copilot integration depth, community mindshare (hiring familiarity: near-universal). GitLab counters with integrated breadth (built-in registry, environments, review apps, security scanning without assembling vendors) — fewer moving parts, one throat to choke, less best-of-breed.
Cost shapes: GitHub: per-seat + Actions minutes + storage (the Actions bill at scale is its own line item — self-hosted runners as the pressure valve); GitLab: per-seat tiers (Ultimate for the compliance/security features — priced accordingly) + runner infra you likely run anyway. TCO differences are usually dominated by runner strategy and included-feature overlap with your existing vendors (if GitLab Ultimate replaces your SAST vendor, the math shifts).
Exit-cost honesty (the part nobody prices): repos move trivially (it's Git). Everything else doesn't: CI definitions (Actions↔GitLab CI is a rewrite — the thin-YAML/scripts-do-the-work discipline is exit insurance), issues/PR history (importers exist, fidelity disappoints), automation/Apps/webhooks (rebuild), and the human muscle memory. Assume 2-3 quarters of platform effort for a real migration — which is why the decision deserves platform-investment diligence now.
My actual recommendation shape: cloud-first company already GitHub-adjacent, values ecosystem/hiring gravity → GitHub Enterprise Cloud (+EMU if identity control demands). Regulated/self-hosted-mandated, or a company that wants the integrated suite and fewer vendors → GitLab (self-managed or dedicated). Either way: keep pipeline logic in scripts, governance-as-code (Terraform provider for settings), and artifact/identity standards platform-neutral — the cheapest exit is the one you never need but always kept possible.
One-liner: 'GitHub sells an ecosystem, GitLab sells an integrated suite with the deeper self-hosting story — at 500 engineers, decide on governance model, compliance posture, and runner economics, then engineer your pipelines and settings so the platform stays a vendor rather than becoming a load-bearing wall.'
Debug a slow git experience: developers complain status/checkout/fetch take 10+ seconds in a large repo. The performance tuning toolkit.
Profile before prescribing — the three suspects have different fingerprints: git status slow = working-tree scanning; checkout/switch slow = object materialization or hooks; fetch slow = negotiation/pack transfer. GIT_TRACE2_PERF=1 on a slow command names the guilty phase precisely.
The git status fixes (usually filesystem scanning):
- fsmonitor (
core.fsmonitor = true— built-in daemon, git 2.37+): stops full-tree stat storms by watching FS events; status on a 300K-file repo goes 8s → 200ms. The single biggest win on big working trees core.untrackedCache = true(pairs with fsmonitor),feature.manyFiles = true(index v4 + tuned defaults)- Sparse-checkout for monorepo users (only materialize the directories you touch —
git sparse-checkout set services/payments libs/shared) withindex.sparse = true: the working tree and index shrink to your slice — most of the cost simply exits - macOS-specific honesty: Spotlight/AV scanning the repo (exclude it), and the laptop-fleet baseline (these settings belong in the org dotfiles/provisioning, not folklore)
The checkout/branch-switch fixes:
- Partial clone artifacts (blob fetch on checkout — expected once per file; a warm cache fixes recurrence), slow post-checkout hooks (profile them — the 'why is checkout slow' answer is a hook rebuilding an index surprisingly often), and
checkoutvsswitchdoing extra work with dirty trees
The fetch/clone fixes:
- Client: partial clone as onboarding default,
fetch.prune, negotiation improvements are mostly automatic on current Git — so first check Git version currency (fleet on 3-year-old Git forfeits fsmonitor, sparse index, commit-graph gains — version currency IS a performance feature) - Server/maintenance side:
git maintenance start(background commit-graph, prefetch, incremental repack on the client); server-side repo maintenance (GitHub/GitLab handle it; self-managed GitLab needs housekeeping tuned), and commit-graph files making log/merge-base operations O(fast) - CI-specific: reference clones/mirrors on runners (never full clone per job), shallow where history isn't needed
The fleet rollout pattern: measure (trace2 telemetry from volunteers or the whole fleet — Microsoft/Dropbox publish exactly this playbook), ship the config via managed dotfiles/includes (include.path to an org gitconfig), track P95 command latency as a developer-experience SLO, and revisit quarterly — repo growth erodes tuning.
One-liner: 'trace2 names the slow phase, then: fsmonitor + untracked-cache for status, sparse-checkout + sparse index for monorepo trees, partial clone + maintenance + current Git for the network path — and ship it all as fleet config with a latency SLO, because ten seconds of git status times eighty engineers is a salary.'
Stacked PRs and the review of large changes: when one PR isn't enough, how do you split, stack, and land a 3,000-line change safely?
The premise to challenge first: most 3,000-line PRs are avoidable — they're batched work that should have merged incrementally behind flags. But some are legitimate (a coherent refactor, a new subsystem, a generated-plus-handwritten migration) — and 'just split it' without technique produces artificial fragments that review worse than the original.
Splitting principles (the craft):
- Split by reviewable claim, not by file count: each PR makes one verifiable assertion — 'this PR introduces the new interface (no callers)', 'this PR migrates callers A-M (mechanical, pattern shown in first file)', 'this PR removes the old path'. A reviewer should be able to hold each PR's entire argument in their head
- Mechanical vs judgment separation: the codemod/rename/format changes in their own PRs (reviewed by spot-check + the script that generated them — include the script), the human decisions in small PRs where attention concentrates. Mixing them is how 2,900 mechanical lines hide the 100 lines that needed eyes
- Refactor-then-behave: preparatory refactors (extract, rename, restructure — behavior-preserving, provable by unchanged tests) land first and separately from the behavior change they enable — the classic Fowler discipline, applied to PR sequencing
Stacking mechanics: PR2 branches from PR1's branch (base = PR1), PR3 from PR2 — each PR's diff shows only its increment. The platform realities: GitHub handles base-retargeting on merge (mostly), but rebases ripple down the stack manually — which is why stacking tools exist (Graphite, git-spr, sapling/jj ecosystems): they automate restack-on-change and stack-wide status. Without tooling, keep stacks ≤3-4 deep or the rebase choreography eats the benefit. GitLab: MR dependencies + --target-branch chains, same idea.
Landing choreography:
- Land bottom-up, promptly — a stack alive for two weeks is a conflict factory; the whole point is rapid sequential merging
- Feature flags bridge the incompleteness: early PRs merge dark, the flag flips after the stack completes — merge order decoupled from release order
- Reviewer assignment per-PR can differ (the mechanical PRs get lighter review; the interface PR gets the senior) — a stack lets you route attention where a monolith couldn't
- The escape hatch when stacks fight the platform: a long-lived integration branch with small PRs into it, then one pre-reviewed merge to main — worse audit granularity, sometimes pragmatic for generated-code avalanches
The cultural function: teams with stacked-PR fluency stop fearing large changes — refactors happen continuously in small slices rather than in dreaded quarterly big-bangs; the technique is upstream of architectural health.
One-liner: 'split by claim, separate mechanical from judgment, land refactors before behavior, stack with tooling (or stay shallow), and merge bottom-up fast — a 3,000-line change reviewed as six honest arguments is safer than one heroic skim.'
Design the incident process for 'the platform is the incident': GitHub/GitLab is down for 6 hours on release day. What breaks, what keeps working, and what did you pre-build?
The blast-radius inventory (know it before the outage — this is the pre-work):
What breaks immediately: merges/PRs (all code flow), CI triggers (webhook-driven), new deploys via the normal path, issue/incident tracking if it lives there (!), status checks (nothing can satisfy protection rules), and — the sneaky ones — everything that authenticates through the platform (OAuth'd internal tools, packages/registries hosted there, Actions-based cron jobs quietly not running).
What keeps working (if you designed it to): running production (obviously decoupled), in-flight GitOps (Argo/Flux hold desired state from their last sync — clusters converge fine; new changes wait), local development (Git is distributed — everyone has the repo; only collaboration is down), and artifact pulls (if your registry isn't the platform's — this is an architecture decision the outage grades).
The pre-built kit (what separates a bad afternoon from a war room):
- Deploy break-glass path that doesn't transit the platform: artifacts already in your own registry + a documented direct path (CD tool CLI / pipeline runnable from a mirror) for the true-emergency deploy — tested quarterly, gated by incident-commander approval, every use postmortem'd. On release day this is the difference between 'release delayed' and 'release impossible'
- Repo mirrors: critical repos mirrored (self-hosted Gitea/mirror service or cross-platform push mirrors) on a schedule — not for daily work, but for 'we need to cut a hotfix and the platform is gone'. The mirror includes the deploy repos (your GitOps desired state is production-critical data)
- Out-of-band incident tooling: if issues/incident tracking live on the platform, the incident about the platform can't be tracked on it — incident channel + status doc live elsewhere (this reads as obvious and is violated constantly)
- Dependency map with owners: which internal systems OAuth through the platform, which crons run as Actions, which packages resolve from its registry — the 6-hour outage surfaces these one surprise at a time unless the map exists
During the incident — the decision cadence: declare it (it's an incident even though it's a vendor — blast radius is yours), communicate the stance ('deploys frozen except break-glass; here's the bar'), resist heroics (most changes can wait 6 hours; the break-glass path is for the genuine exception — an outage-driven cowboy deploy that breaks prod converts a vendor incident into your incident), and batch the recovery: when the platform returns, the merge/deploy thundering herd needs sequencing (merge queue drains, CI backlog prioritization — the hour after recovery is its own mini-incident).
Afterward: vendor postmortem consumed, your postmortem run (what surprised you = what wasn't on the map), and the honest strategic review — the answer is almost never 'leave the platform'; it's 'shrink the single-points: registry independence, GitOps state mirrored, break-glass rehearsed'.
One-liner: 'design so the platform's outage pauses change but never runtime: own your registry, let GitOps agents hold state, mirror the critical repos, keep incident tooling off-platform, and rehearse the one break-glass deploy path — then a 6-hour outage is a delayed release, not a company outage.'
Author identity and the ownership graph: CODEOWNERS at scale, orphaned code, and building an ownership system that survives reorgs.
Why this is a real problem: at 300 repos / 60 teams, 'who owns this code' decays constantly — people leave, teams reorg, services transfer — and every decayed edge shows up operationally: PRs waiting on departed reviewers, incidents with no page-able owner, security findings with no fixer, and CODEOWNERS files silently referencing ghosts (a silent protection failure: required-review rules that resolve to nobody either block everything or gate nothing, depending on platform semantics — know which yours does).
The design principles:
- Teams, never individuals: CODEOWNERS entries reference teams (
@corp/payments-platform), membership managed in the IdP and synced — individuals churn quarterly; teams survive with their membership updated by the joiner/leaver process automatically. An individual username in CODEOWNERS is a future orphan - Ownership as data, not just review-routing: a service/component catalog (Backstage-style, or a simple owners.yaml convention) as the source of truth — team, escalation, tier, on-call — from which CODEOWNERS files are generated (CI check: CODEOWNERS matches catalog; drift fails the build). The catalog serves incidents/security/cost too; CODEOWNERS is one projection of it
- Coverage as a metric: % of repos/paths with valid (resolving, non-empty-team) ownership, tracked on the platform dashboard — orphaned-code discovery by report, not by incident. New-repo templates require an owner declaration at creation (unowned-at-birth is the leak to plug first)
- Granularity discipline: repo-level default owner + path-level owners only where authority genuinely differs (auth/, payments/, workflows/, migrations/) — a 200-line CODEOWNERS microfile is a maintenance burden that will rot; coarse-but-correct beats precise-but-stale
The reorg survival mechanics (the hard part):
- Reorgs execute as catalog PRs (team X's services → teams Y and Z) → generated CODEOWNERS updates fleet-wide in one reviewed campaign — versus the traditional six months of tribal-knowledge decay
- Orphan protocol: departing team's services get explicit disposition (new owner, or deliberate orphan status with a sunset plan) — 'unowned' as an unrepresentable state in the catalog schema; the quarterly review reads the exceptions list
- Transfer hygiene: ownership transfer includes the operational bundle (runbooks, dashboards, alerts re-pointed, secrets/access re-scoped) — a checklist generated from the catalog entry, because CODEOWNERS-only transfers leave the pager pointing at the old team
The cultural honesty: ownership systems fail socially before technically — teams refuse orphans ('not ours'), and 'owner' without capacity is a lie that surfaces at incident time. The catalog needs an executive-sponsored rule: everything tier-1/tier-2 has a staffed owner, and the exceptions list is leadership's problem, not the platform team's.
One-liner: 'CODEOWNERS references teams generated from a service catalog that is the single source of ownership truth — coverage measured, reorgs executed as catalog changes, orphans made explicit and escalated — because every ownership edge that decays silently becomes a 2am incident with nobody to page.'
Build vs buy for developer platform tooling on top of GitHub/GitLab: when do you write custom Apps/bots, and how do you keep them from becoming unowned legacy?
The decision framework (in order):
- Platform-native first: rulesets, merge queues, environments, required workflows, CODEOWNERS — the platforms absorbed a decade of common bots; check the changelog before building (the org that maintains a custom merge-bot in 2026 is maintaining a museum piece — the platforms shipped it)
- Marketplace/ecosystem second: established vendors/OSS for the commodity layers (renovate over a custom update bot, established review-routing tools) — with the supply-chain diligence any dependency gets (SHA-pinned, scoped permissions, vendor viability assessed)
- Build third — when it's genuinely yours: the criteria that justify custom: (a) org-specific policy encoding nothing generic expresses (your promotion gates, your compliance evidence shapes, your catalog integration), (b) leverage across many teams (a bot serving 3 repos is a hobby; 300 repos is a platform), (c) the workflow is a competitive/cultural differentiator you'll keep investing in
How to build them so they don't rot (the actual question):
- GitHub Apps / GitLab bot-users with scoped tokens — never PATs from a human account (the departure of that human is an outage; the App identity is institutional). Minimal permission grants, per-installation review
- Production software discipline, explicitly: the bot has an owner team (in the catalog!), SLOs (webhook processing latency, error rate), monitoring/alerts, a runbook, and an on-call answer — 'the merge bot is down' at 60-engineer scale is a company-wide work stoppage; it's tier-1 whether you admit it or not
- The architecture that survives: stateless webhook handlers + queue + reconciliation loop (the events-plus-resync pattern — every durable platform bot converges state, never trusts event delivery alone), config-as-code in the repos it serves (behavior changes are PRs, not redeploys), and staging installations (a bot change tested against a sandbox org — bots that can merge/close/deploy get the same rollout care as anything with prod access)
- The legacy-prevention ratchet: annual review against criterion #1 — has the platform absorbed this yet? Retiring a custom bot when the native feature ships is a win, budgeted and celebrated as such; the graveyard alternative is five unowned bots whose original authors left, each load-bearing, none understood (every platform team inherits at least one of these — the review exists to cap the count)
- Escape-hatch documentation: what happens if the bot is down — manual fallback paths for its critical functions, because a bot that's a hard dependency with no bypass is a new single point of failure you built voluntarily
The portfolio shape at a healthy 500-engineer org: 2-4 genuinely custom high-leverage Apps (promotion/compliance/catalog integration), a curated set of vendor/OSS tools, aggressive use of native features, and a written bar for adding to the first category.
One-liner: 'exhaust native and marketplace options first; build only org-specific, many-team leverage — and what you build gets an owning team, SLOs, reconciliation-based architecture, and an annual "has the platform absorbed this" review, because unowned load-bearing bots are the platform team's signature failure mode.'
Draw me the merge queue mentally: walk through what happens when three PRs enter a queue and the middle one fails.
The setup: PRs A, B, C — each approved and green against their view of main — enter the queue at 10:00.
The sequence, precisely:
- The queue constructs three speculative merge commits in parallel:
main+A,main+A+B,main+A+C+B... wait — no:main+A,(main+A)+B,(main+A+B)+C— each candidate assumes everything ahead of it lands. CI runs on all three simultaneously (optimistic batching) main+Agoes green → A is eligible to land the moment its turn arrivesmain+A+Bfails → B is ejected back to its author with the failing result (their PR was incompatible with A — a semantic conflict both individual CIs missed)- C's speculative base (
main+A+B+...) is now invalid — it assumed B. The queue rebuilds C's candidate asmain+A+Cand re-runs CI - A lands (fast-forward), C lands when its rebuilt run goes green. Main was never broken at any point — that's the invariant the whole machine exists to hold
The operational corollaries to narrate: a failure costs the whole queue behind it a re-test (why flakes are queue poison and why ejection-rate is a tracked metric); batch size tunes optimism vs rebuild cost; and queue latency ≈ CI duration under load — the queue converts 'CI is slow' from an annoyance into a merge-throughput ceiling everyone feels.
Why interviewers love this question: it tests whether you understand the queue as a speculative pipeline with rollback rather than a magic serializer — the same mental model as CPU speculative execution, applied to merges.
Trace a GitOps promotion end-to-end: a developer merges a fix — draw the path to production and every audit artifact created along the way.
The path, artifact by artifact:
- App repo: PR merged (artifact: the PR — review approvals, CI checks, linked issue). Squash commit on main (artifact: signed commit)
- CI: builds image → pushes by digest with SBOM + SLSA provenance + cosign signature attached (artifacts: the attestations, bound to the CI workflow's OIDC identity)
- Deploy repo, dev: automation PRs the digest pin into
envs/dev/— auto-merged on green (artifact: the pin commit — when dev got it, by what automation) - Staging promotion: gates queried (soak metrics, scan freshness) → promotion bot opens the staging PR → merged (artifact: the promotion PR with gate evidence in its body)
- Prod promotion: same mechanic, stricter — CODEOWNERS forces release-approver review (artifact: the human approval, attached to a one-line diff of a digest)
- Argo/Flux syncs: desired state converges to the cluster (artifact: sync history — what applied, when, health outcomes); admission control verifies signature/provenance at schedule time (artifact: admission decision logs)
- Rollback, if needed: revert the promotion PR — same trail in reverse
The interview payoff — answer 'how do you know what's in prod and how it got there?': join git log of the deploy repo's prod directory with the attestation store: every deployed digest → its promotion PR → its build provenance → its source commit → its code review. No tribal knowledge, no deploy spreadsheet — the process is the audit.
Git LFS in production: how it actually works, where it hurts, and the decision framework for adopting it.
The mechanism: LFS replaces large files in Git with pointer files (~130 bytes: an OID + size); the real content lives in an LFS store (platform-provided or self-hosted). Clean/smudge filters swap pointers↔content transparently on checkout/commit for tracked patterns (.gitattributes: *.psd filter=lfs). History stays light; content fetches happen at checkout for the version you need, not all versions ever.
Where it genuinely wins: design/game/ML assets (large binaries with churn — the case where plain Git stores every version forever in every clone), keeping repo packs small enough that normal Git perf tooling works, and per-version fetching (checkout fetches HEAD's blobs, not 400 historical versions).
Where it hurts (the scar list):
- The forgotten-install failure: a user without
git lfs installclones and gets... pointer files as content — builds fail with cryptic 'file is 130 bytes of YAML' errors; or worse, they commit real binaries alongside LFS pointers, forking the storage model. Fleet provisioning must own LFS setup - The egress bill: platform LFS storage+bandwidth is metered — a hot repo with CI cloning LFS content on every build turns into a surprising invoice; CI needs LFS caching (or
GIT_LFS_SKIP_SMUDGE=1when builds don't need the assets) - Locked to the store: pointers reference a server — migrating platforms means migrating LFS storage too (mirrors don't just work); the repo is no longer self-contained, which also breaks 'Git is distributed' assumptions (no LFS server = no content)
- History surgery required for retrofits: adopting LFS only helps future commits unless you rewrite history (
git lfs migrate import --everything) — the full SHA-rewrite coordination ceremony - Merge/diff semantics: binaries don't merge — LFS adds file locking (
git lfs lock) as the workaround for unmergeable-asset workflows (design teams), which is coordination-by-mutex with all its friction
The decision framework:
- Large binaries, versioned alongside code, needed at checkout → LFS is correct
- Build artifacts/dependencies → wrong repo citizen entirely: registry/artifact store (the question is upstream of LFS)
- ML models/datasets at serious scale → purpose-built (DVC, lakeFS, object storage + manifest files) — LFS at tens-of-GB per file is running past its design center
- Occasional smallish binaries with low churn → plain Git is honestly fine below ~50MB total; don't add the operational surface preemptively
One-liner: 'LFS keeps history light by storing pointers and fetching content per-checkout — adopt it for genuinely co-versioned assets with churn, provision the client fleet properly, cache in CI, and remember that artifacts and datasets have better homes than any flavor of Git.'
The reflog, the object database, and data recovery: 'I deleted my branch / reset --hard / rebased away my work' — the complete rescue playbook.
The recovery-enabling truth: Git almost never deletes objects immediately — commits 'lost' by branch deletion, reset, or rebase remain in the object database until garbage collection (default: unreachable objects survive ~2 weeks, reflog entries ~90 days). Recovery is about finding the SHA; the data is nearly always still there.
The rescue playbook by scenario:
reset --hardto the wrong place:git reflog— every HEAD movement, newest first:HEAD@{1}is where you were.git reset --hard HEAD@{1}undoes the undo. (Per-branch reflogs too:git reflog show mybranch)- Deleted a branch: the commits live on —
git reflog(if recently checked out) orgit fsck --lost-found(unreachable commit sweep) finds the tip SHA →git branch rescued <sha> - Rebase went wrong: the pre-rebase tip is in reflog (
HEAD@{N}before the rebase started — reflog annotates 'rebase (start)') and also atORIG_HEADimmediately after →git reset --hard ORIG_HEADaborts history - Lost uncommitted staged work: the index wrote blobs —
git fsck --lost-founddumps dangling blobs to.git/lost-found/(contents without filenames — grep for distinctive strings). Painful but real. Never-staged work: Git never saw it — editor local-history/IDE snapshots are the only hope (the honest answer) - Stash disasters: dropped stashes are dangling commits —
git fsck --unreachable | grep commit+ inspect, or the stash reflog before it's gone - The nuclear inventory:
git fsck --lost-found,git log --all --walk-reflogs, and remote-tracking refs (origin/branchstill holds what the remote had) — between these three, a 'catastrophic' local loss is usually a 10-minute recovery
The senior framing — why this matters beyond rescue: understanding recovery removes fear, and fear is what makes people avoid rebase/reset/history hygiene entirely ('I don't touch it, I might lose work'). Teams fluent in reflog use Git's full vocabulary confidently.
The limits, stated honestly: gc + expired reflogs = genuinely gone (don't run gc --prune=now mid-panic!); uncommitted-unstaged work has no Git safety net (commit early, commit often — WIP commits are free and squashable); and none of this applies to the remote — force-pushed-away remote history recovery is the platform-events/mirror story, not reflog.
One-liner: 'Git is a museum that rarely throws anything out for 90 days — reflog finds where you were, fsck finds what's orphaned, ORIG_HEAD undoes the last surgery; learn the rescue kit and the scary commands stop being scary.'
Feature flags and Git strategy: how flags change branching, and the discipline that stops flag debt from rotting the codebase.
The structural relationship: trunk-based development requires a way to merge incomplete work safely — flags are that mechanism: code merges dark (flag off), integrates continuously (compiled, tested, refactored-with), and releases on flag flip. The flag decouples deploy from release — the single most consequential delivery idea of the last decade, and it's what makes short-lived branches viable for multi-week features.
What changes in Git practice when flags arrive:
- Branch lifetime collapses: the three-week feature branch becomes daily PRs behind
payments.retryV2.enabled— merge conflicts, integration surprises, and review-batch size all shrink together - Release coordination exits Git: no release branches for feature timing — main deploys continuously; marketing ships by flipping flags. Rollback of a feature = flag off (seconds), not revert-and-redeploy (minutes-hours) — and it's per-tenant/percentage targetable
- The testing contract changes: CI tests both flag states for active flags (at minimum: default-state suite + flag-on suite for the feature's own tests) — untested flag combinations are where 'the flag flip broke prod' lives
The debt discipline (the part orgs fail): every flag is a fork in the code — two paths to read, test, and reason about. Flags left after their decision resolved are pure debt: dead branches guarded by config nobody remembers, interacting combinatorially (2^n states across n stale flags), and eventually load-bearing in the off position (the 'temporary' flag from 2023 that something now depends on).
- Flags have types with lifecycles: release flags (temporary by definition — created with an expiry date and a removal ticket in the same PR that adds them), ops flags (kill switches, load-shedding — permanent, documented as operational controls), experiment flags (die with the experiment), entitlement flags (permanent, they're product config). The type determines the cleanup contract
- Removal is enforced, not aspired: expired-flag reports in CI (the flag SDK's metadata + a linter), flag-count-per-service on the platform dashboard, and cleanup PRs as scheduled work — the healthy pattern is 'flag removed within 2 sprints of 100% rollout'. Stale-flag count is tech-debt you can measure
- Code hygiene: flag checks at the edges (one branch point, not
if flagsprinkled through 12 files) — makes both testing and removal a small diff
One-liner: 'flags are what let trunk-based development scale past trivial features — merge dark, release by config, roll back in seconds; the tax is that every flag is a code fork with a mandatory funeral, so create each one with its type, its expiry, and its removal ticket already attached.'
Multi-region / distributed teams and Git workflows: follow-the-sun development, handoff discipline, and the timezone-shaped failure modes.
The timezone-shaped failure modes (name them first — they're the question's substance):
- The 24-hour review round-trip: SF engineer opens a PR at 4pm; Bangalore reviewer comments at their 11am; SF responds next their-morning — a three-comment exchange takes three days. Review latency across timezones is the dominant velocity tax of distributed teams, and it silently drives oversized PRs (why open small PRs when each costs a day?) — the exact death spiral small-PR culture exists to prevent
- Merge-conflict handoffs: two sites touching adjacent code land conflicting changes during each other's night — discovered at day-start as a rebase surprise
- The broken-main morning: a late-day merge breaks main after the merging team leaves; the next timezone inherits a red main and no context — their whole morning is archaeology
- Deploy-timing collisions: site A's release window is site B's peak traffic
The mechanics that work:
- Review SLAs with timezone awareness: every PR gets a reviewer in the author's timezone overlap (CODEOWNERS teams staffed cross-region, or explicit follow-the-sun review rotations) — the goal is first-response within the author's same working day. Cross-region review reserved for the genuinely specialized paths
- Ownership boundaries reduce collision surface: align service/module ownership with sites where feasible (not silos — primary ownership) so adjacent-code races are the exception; the catalog knows which site owns what, and cross-site changes get flagged for coordination
- Main-health as a handoff contract: merge queues + revert-first make 'inherit a red main' structurally rare; when it happens anyway, the handoff note covers it (see below). Deploy windows per region codified in the CD config, not tribal memory
- The handoff ritual (borrowed from ops, applied to development): end-of-day async note per team — in-flight work state, PRs awaiting the other site, known landmines, main status. Fifteen minutes of writing saves the receiving site's first hour. Same discipline for incident handoffs, with more ceremony
- Async-first artifacts: decisions in PR descriptions/ADRs/issues rather than meetings-that-one-timezone-attends — the repo becomes the shared memory; synchronous overlap hours (the precious 2-3h window) spent on discussion, never on status transfer that a document could carry
- Tooling assists: scheduled merge trains timed to land before handoffs (not after), bot escalation of PRs aging past SLA to the next timezone's queue, and dashboards visible identically from everywhere
The cultural underwrite: distributed Git workflow succeeds exactly to the degree the org treats written context as first-class — teams that need a meeting to transfer state pay the timezone tax at compound interest; teams whose PRs, ADRs, and handoff notes carry full context barely notice the ocean.
One-liner: 'timezone pain concentrates in review latency, collision surprises, and context-free handoffs — fix it with same-timezone review paths, site-aligned ownership, queue-protected main health, and a written handoff ritual, because the repo plus good documents is the only meeting room every timezone can attend.'
Interactive rebase mastery: fixup workflows, autosquash, rebase --onto, and rewriting a messy 20-commit branch into a reviewable history.
The tools, composed into a workflow:
rebase -iverbs:pick(keep),reword(edit message),edit(stop to amend),squash(merge into previous, combine messages),fixup(merge, discard message),drop, and reordering by moving lines. The editor view IS the history you're sculpting- The fixup workflow (the daily-driver pattern): during review, address feedback with
git commit --fixup=<sha-being-fixed>— commits namedfixup! <original subject>accumulate; at the end,git rebase -i --autosquashautomatically repositions and squashes them into their targets. Reviewers see clean incremental fixes during review; the final history shows coherent commits with fixes folded in — both audiences served rebase --onto— the transplant tool:git rebase --onto newbase oldbase mybranchmoves the commit range (oldbase..mybranch) onto newbase — the answer to: branch built on a branch that merged/rebased/died ('rebase my last 4 commits onto main, ignoring the dead parent branch'), extracting a slice of commits to a fresh branch, and the post-history-rewrite migration recipe--update-refs(git 2.38+): rebasing a stack updates the intermediate branch pointers along the way — stacked-PR workflows without manual per-branch rebasing; the flag that made native stacking viable
The messy-20-commit-branch recipe:
- Safety line first:
git branch backup/feature(recovery is a pointer away regardless of reflog) git rebase -i main— first pass: drop the debug/WIP noise (wip,fix typo,revert the revert), reorder into logical groups (all migration commits together, all API commits together)- Second pass: squash/fixup each group into its coherent commit; reword into real messages (the why, the trade-offs — review archaeology two years later reads these)
- Target shape: 3-5 commits, each building and testing independently (
git rebase -i --exec 'make test'runs the suite at every rewritten commit — the flag that guarantees bisectable history), each a reviewable claim - Force-push
--force-with-lease(never bare--force— lease refuses if the remote moved under you: a teammate's push survives your rewrite)
The judgment layer (when NOT to polish): squash-merge repos make branch-history sculpting mostly moot (the platform flattens it anyway — spend the effort on the PR description instead); shared branches are never rewritten (the iron rule); and know your team's history philosophy — 'clean rewritten history' vs 'true messy history' is a legitimate values divide; what's not legitimate is not knowing which game you're playing.
One-liner: 'commit --fixup during review, autosquash at the end, --onto for transplants, --exec to prove every commit builds, --force-with-lease to push it — history is a communication artifact, and interactive rebase is its editor.'
REST vs GraphQL vs webhooks vs git protocol: you're building org-wide analytics (DORA metrics) from GitHub/GitLab data — design the data pipeline.
The goal: deployment frequency, lead time for changes, MTTR, change-failure rate — per team/service, trustworthy enough to guide investment (and survive the scrutiny of the team that looks bad on it).
The data-source design (each metric wants different plumbing):
- Webhooks as the event backbone: PR merged, deployment status, workflow runs — pushed to your pipeline (queue → warehouse) in near-real-time. Verified, deduped (delivery IDs), and — per the reliability pattern — backed by reconciliation: nightly API sweeps repair the gaps webhooks missed, because analytics built on at-least-once delivery alone quietly undercounts
- GraphQL for the backfill and the sweeps: one query pulls PR + reviews + commits + timeline in a shape REST needs 6 calls for — rate-limit-efficient for bulk historical loads (the 2-year backfill that makes trends meaningful on day one) and the nightly reconciliation
- REST where it's the only door (some endpoints/events), with conditional requests for cheap polling of the few things that lack webhooks
- The git protocol itself for commit-graph truths: clone/fetch +
git loganalysis for code-change metrics (churn, coupling, commit patterns) — richer and rate-limit-free vs API-paginating commits; run against mirrors, not the platform - The critical join — deploy events from your CD system, not just the platform: lead-time's clock stops at production deploy, which lives in Argo/your deploy records/the GitOps repo history — platform data alone measures merge-time, not lead-time. The GitOps deploy-repo history (promotion PRs with timestamps) is often the cleanest deploy ledger you own. MTTR needs the incident system joined in (PagerDuty/incident records). DORA is a three-system join (SCM + CD + incidents); single-source DORA dashboards are directionally decorative
The pipeline shape: webhooks → queue → staging tables (raw events, immutable) → modeled layer (dbt-style: PR facts, deploy facts, incident facts, with the service-catalog join for team attribution) → metric marts → dashboards. Raw-event immutability matters: metric definitions will be contested and revised ('does a revert count as a failure?') — recompute from raw beats re-collecting.
The definitional landmines (where these projects die):
- Change-failure rate needs a 'failure' definition everyone signs: deploys followed by rollback/hotfix/incident-tag within N hours — pick, document, apply uniformly; hand-tagged failure data decays in a quarter
- Lead time start: first commit? PR open? — pick per the behavior you want to influence (PR-open→prod measures the system; first-commit→prod punishes thoughtful drafting)
- Team attribution via the catalog, not repo names or committer emails (both lie at org scale)
- Goodhart defense: publish as team-self-service trends, never leaderboards — DORA gamed (deploy-count inflation, failure under-tagging) is worse than DORA absent; the metrics guide conversations and investment, and the deal is they're never used for individual performance review (say this out loud, in writing, or watch the data quality die)
One-liner: 'webhooks for freshness, GraphQL for backfill and repair, git itself for commit truths, and your CD + incident systems for the joins that make it actually DORA — model from immutable raw events, define "failure" contractually, attribute via the catalog, and publish trends not leaderboards.'