Skip to content

feat(bundler-plugin): capture the build's commit SHA for repo source fetching - #2

Merged
krassx merged 9 commits into
mainfrom
feat/bundler-commit-sha
Sep 6, 2026
Merged

krassx merged 9 commits into
mainfrom
feat/bundler-commit-sha

Conversation

@krassx

@krassx krassx commented Sep 5, 2026 •

Copy link
Copy Markdown
Contributor

Why

A remapped JS frame can only show its original source if the uploaded map embeds it in sourcesContent. When it does not, the worker gives up — symbolfiles/sourcemap.py::_regenerate_context reports context_unavailable = 'no_sources_content' and the frame ships with no source at all.

esbuild, Rollup, Vite (incl. hidden modes) and a real Next.js production build all embed sourcesContent by default. The gap is tsc --sourceMap without inlineSources, bare terser, and Rollup's opt-in sourcemapExcludeSources. Real, but uncommon.

Option B is to fetch the file from the repository the customer connected to the app. Datadog requires sourcesContent; Sentry expects it and treats public-URL fetching as explicitly discouraged; BugSnag and Rollbar fetch over plain HTTP from the path in the map. Nobody fetches from a connected VCS repo.

What the research found

Almost the entire pipeline already exists. The only genuinely missing piece was the commit SHA of a JavaScript build.

Piece Where State
Commit SHA field appserver models/build.js:232-240 exists — validated (sanitizeVcs), partial-indexed
Build write path POST /v2/apps/{token}/builds → builds.service.js:793 exists
Canonical VCS resolver bugsee-cli vcs-metadata exists
Crash → build → SHA worker jobs/issues.py::_lookup_build_vcs exists, live for AI Insights
Ref preference candidate_customer_refs — a commit beats tags/branches exists
Repo fetch fetch_snippet + vcs_auth.py exists
SHA on a JS build — missing → this PR

No new backend schema is needed. (get_build_by_commit is appserver-MCP-only, has no REST route, and is the reverse direction — not this join.)

What this PR builds

resolveVcsMetadata / isWorkingTreeDirty / resolveCommitOverride in @bugsee/bundler-plugin-core, plumbed through resolvePluginOptions (vcs, commit, allowDirtyCommit, projectRoot, onNotice) → runPluginUpload → uploadSourcemaps.

It spawns bugsee-cli vcs-metadata rather than reimplementing detection. That resolver is wire-compatible with the Android Gradle plugin's VcsMetadataResolver and the appserver's sanitizeVcs; the iOS agent and the fastlane plugin were both deleted in favour of shelling out to it. A fourth implementation, in JavaScript, is the one that drifts.

The dirty-tree gate is the one case the canonical resolver has no notion of. A SHA that doesn't describe the built source would make the backend display the wrong lines of code for a frame. Untracked files aren't dirt. "Cannot tell" is not "dirty" — the commonest deployment (CI container, no git, SHA from the provider's env) reports unknown.

Degradation

Nothing here can fail a build. No git · not a repository · no commits yet · shallow clone · detached HEAD · bugsee-cli missing or too old · resolver threw · probe hung · a host's notice sink threw · process.cwd() threw → all mean "no commit recorded".

Verified against the real bugsee-cli binary and real repositories: clean → {commit_sha, branch}; dirty → {branch}.

Not in this PR — the open decision

No bugsee-cli command registers a build without an artifact: upload build requires --artifact and hard-codes request_artifact_upload: true; upload build-info requires ≥1 sidecar and an org feature flag; debug-files upload carries a closed 5-key metadata struct.

Recommended: a register-only mode (upload build --no-artifact) reusing the payload xcode.rs:629-730 already builds with vcs nested — no appserver change at all.

⚠️ Needs a decision: the join is environment.app.{version, package_id, build} → build record. The SDK reports those from launch(); the plugin defaults --version/--build to 0.0.0/0. They must agree.

Review

Six rounds. Rounds 2–4 each found problems the previous round's fixes had introduced; round 5 was the first with no functional defect. Notable:

  • a build-breaking process.cwd() in the synchronous config path, outside every containment layer, firing even for a disabled plugin;
  • a memoization added then deleted — it never fired for the multi-output case it targeted (bundlers await each bundle.write) while putting a process-wide map in front of a value whose key omitted allowDirtyCommit, so one instance could record a SHA for a dirty tree;
  • two green tests pinning contradictory semantics for vcs: false;
  • a fix documented in a commit message that never happened — an unasserted string replace silently no-opped;
  • and finally a pin that did nothing, plus tautological evidence for it: a diff.autoRefreshIndex override added on an unverified claim. Measured, porcelain git diff --quiet HEAD -- content-compares at both settings; only the plumbing diff-index is stat-sensitive. The "proof" that the pin mattered was an argv assertion catching an argv change. Removed; the surviving -c diff.relative=false pin is backed by a real-git behavioural test (probing a subdirectory must still see a dirty file at the repo root), and the porcelain-vs-plumbing choice is now guarded too.

Gates

pnpm ci:local green. 197 tests; 100% line/fn/stmt, ~99% branch. Per-entity mutator loop across eight rounds: 67 mutations, all caught — survivors were fixed by strengthening the assertion, never the implementation.

🤖 Generated with Claude Code

…fetching

A remapped JS frame can only show its original source if the uploaded map embeds
`sourcesContent`. When it does not, the worker gives up — `_regenerate_context`
reports `no_sources_content` and the frame ships with no source at all.

The fix is to fetch the file from the repository the customer connected to the
app. Everything needed for that already exists on the backend: `build.vcs.commit_sha`
(validated + indexed), the `_lookup_build_vcs` join from a crash's app version to a
build record, `candidate_customer_refs` preferring a commit over tags and branches,
and `fetch_snippet` for GitHub/GitLab/Bitbucket. The single missing piece was that
nothing on a JavaScript build ever captured the commit it was made from.

This captures it. It does NOT reimplement CI/git detection: `bugsee-cli vcs-metadata`
is the canonical resolver, contractually wire-compatible with the Android Gradle
plugin and the appserver's `sanitizeVcs`, and the iOS agent and fastlane plugin were
both deleted in favour of shelling out to it. A fourth implementation, in JavaScript,
is the one that would drift. So the plugin spawns it and passes the result through
opaquely — a provider added on the backend needs no release here.

What it adds on top is the one case the canonical resolver has no notion of: a DIRTY
WORKING TREE. A SHA that does not describe the built source would make the backend
fetch and display the WRONG lines of code for a crash frame, so it is dropped by
default (`allowDirtyCommit` opts out). Untracked files are deliberately not dirt —
they change no committed file, and counting them would disable the feature on most
working checkouts. Crucially "cannot tell" is NOT "dirty": only a definite
`git diff --quiet HEAD --` exit 1 drops the SHA, because the most common deployment
shape there is — a CI container with no git binary and no .git, whose SHA came from
the provider's env var — reports unknown, and treating that as dirty would disable
the feature exactly where it works best.

Nothing here can fail a build. No git, no repository, no commits yet, a shallow
clone, a detached HEAD, a bugsee-cli that is missing or too old, a resolver that
threw, a probe that hung — every one degrades to "no commit recorded". Verified
against the real bugsee-cli binary and real repositories: a clean checkout yields
{commit_sha, branch}, a dirty one yields {branch}.

The delivery half is designed but NOT built — no bugsee-cli command can register a
build record without an artifact today. Options and the one open decision are in
docs/design/source-maps.md §9.4.

Also exports run-cli's spawn adapter as `spawnProcess`, so the git probe runs through
the same hardened options as the CLI spawn (never a shell, stdin ignored).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
…eam, and silence

Two independent read-only reviews of the capture slice. Every real finding fixed
test-first; the ten surviving mutants they named are now caught, plus twelve more
against these fixes.

SEV1 — the plugin could fail a build. `projectRoot` defaulted to `process.cwd()`
inside `resolvePluginOptions`, which runs SYNCHRONOUSLY from the unplugin factory at
config-evaluation time — outside every containment layer, not gated on failOnError.
`process.cwd()` throws ENOENT/uv_cwd when the process's working directory has been
unlinked, which a build script that recreates its own directory really does. So a
plugin whose entire contract is that it cannot break a build could break one, and
would do it even when fully disabled, since the field was computed before `enabled`
was consulted. The cwd is now resolved lazily, inside the contained collection path,
and a disabled feature resolves nothing at all.

The injected `env` seam leaked. `resolvePluginOptions` read BUGSEE_BUILD_COMMIT from
the injected env while `resolveVcsMetadata` fell back to `process.env` behind it, so
an ambient value defeated an injected one — the exact duplication the comment claimed
to be avoiding. The plugin layer now passes an empty env, having already consulted it.

The module said nothing, while a comment claimed it turned a silent server-side
discard "into something the build log can say out loud". It does now, for the two
cases a user can act on: a malformed commit override (naming the value they typed)
and a SHA dropped as dirty (naming allowDirtyCommit). Routed through the plugin's own
onError sink so a host can capture it.

Also: collection is memoized per project root — writeBundle fires once per OUTPUT and
an SSR build emits several, so each was forking vcs-metadata AND git diff concurrently
for one answer; released on settle, so a commit made mid-watch is still picked up.
`vcs: false` with an explicit `commit` now honours the commit instead of discarding it.
One timer instead of two in the dirtiness probe (the loser was never cleared, leaking a
closure per rebuild under --watch). And `spawnProcess`'s signal message names the
command that actually died — it serves `git` now, and hard-coded "bugsee-cli" into a
line blaming the wrong binary.

Test strengthening, all mutation-verified: the regex anchor is load-bearing (a suffixed
hex run was accepted whole); the dirty probe's ARGUMENT is asserted (a process.cwd()
mutant silently probed the wrong tree in a monorepo); the abort signal is asserted to
reach the spawn AND to fire on timeout; the real dirty-repo path runs with NO probe
injected, so binding the default to "always clean" — which disables the feature in
production while every stubbed test stays green — now fails; shallow clone, staged-only
and unborn-HEAD cases added; real-git tests insulated from the developer's global config
(commit.gpgsign=true would have failed the suite) and split so one failure hides nothing.
Three JSON fixtures that asserted `undefined` where the buggy path also yields
`undefined` are relabelled as regression fixtures rather than dressed up as coverage.

The user-facing doc no longer claims the feature works end to end: delivery is not
built, and it now says so. Limitations the review surfaced — the probe running after
the build wrote its output, covering the whole repo, and not checking the reported SHA
against HEAD — are documented for users and recorded in design §9.5.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
…aning per option

A fresh review of both prior commits. Most of what it found was introduced by the
FIXES in 225422a, which is the argument for the convergent gate rather than against it.

The memoization is REVERTED. It was added so a multi-output SSR build would fork
`vcs-metadata` once instead of once per output — except bundlers await each
`bundle.write` in turn (vite's `for (const output of ...) await bundle.write(output)`;
rollup's `hookParallel('writeBundle')` sits INSIDE each write), so the in-flight entry
was always gone by the next output and the memo never fired for the case it existed
for. What it did do was put a module-level map, shared by every plugin instance in the
process, in front of a value that depends on `commit`, `allowDirtyCommit`, the notice
sink and the injected resolver — none of which were in the key. Two plugin instances
defaulting `projectRoot` to the same cwd, one with `allowDirtyCommit: true`, and the
other joins its result and records a SHA for a dirty tree. That is precisely the
invariant the feature exists to protect. Shared mutable state for a benefit that was
never obtained; the honest fix is to delete it.

`vcs: false` now means OFF at every layer. Round 1 made the resolver honour an explicit
`commit` when disabled while the plugin layer short-circuited before ever calling it —
so the two surfaces answered the same configuration differently, and the suite carried
a green test pinning each contradictory answer. One option, one meaning: off records
nothing, and a caller who wants a commit leaves `vcs` alone and sets `commit`.

Notices move off `onError` to a new `onNotice` option. `onError` is documented as
taking a contained failure and everywhere else receives an `Error`; hosts do
`e.message`, `e instanceof Error`, or fail the pipeline on a non-empty list, and a
plain string about a dirty tree breaks all three. A throwing sink is now swallowed too
— `resolveVcsMetadata` is public API documented as never throwing, and an unguarded
sink both made that conditional and cost the caller the whole metadata object (branch,
repo, provider) rather than one message.

Smaller, all real: the malformed-commit notice said "no commit will be recorded" when
the code goes on to record the DETECTED one two lines later — the package's own test
asserts that fallback. An empty `BUGSEE_BUILD_COMMIT` warned on every build forever,
though CI templating of an unset variable produces `''` constantly and the token
resolution beside it already treats `''` as absent. The signal-termination line now
names no binary at all: "bugsee-cli" was wrong once this adapter also ran `git`, and
`command` is equally wrong because on the DEFAULT install it is `process.execPath`, so
an OOM-killed CLI reported that node had died. And the ORDERING NOTE I added in round 1
stated the ordering backwards — measured, it is `slot-freed -> caller-resumed`, which
is *why* a sequential watch-mode caller finds the slot free; as written a maintainer
acting on it would have changed the wrong thing.

The usage doc claimed `dryRun` logs what was captured. It didn't — the plugin discards
`uploadSourcemaps`' return value entirely, so a capture-only feature had no way to show
a user it had done anything. It does now.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
…round 2's guard

Round 2's fixes held up. Round 3 found one narrow instance of the same pattern that
made round 2 necessary, plus doc drift.

Round 2 made a throwing `onNotice` harmless INSIDE the resolver and wrote the rule down
absolutely: losing the whole metadata object because a host's logger blew up is a far
worse trade than losing one message. But the dry-run diagnostic that the same commit
added calls the sink DIRECTLY from `collectVcs`, inside the try whose catch returns
undefined — so a throwing sink discarded the entire VcsMetadata object, branch and repo
and provider included. Exactly the invariant round 2 wrote down, violated by the feature
round 2 added, at the layer that owns the sink. Guarded there now, and pinned by a test
at that layer rather than only at the resolver — which is why it slipped through.

The malformed-commit notice also fired on the `enabled: false` path, where round 2 had
reworded its tail to promise a fallback to "the detected commit, if any" — on the one
path that detects nothing and records nothing. Two changes in one commit that were never
composed. The off-return now precedes the notice.

Two tests carried names describing things that are no longer true or that they cannot
see: one asserted console fallback under a name about the removed `onError` routing (and
was a straight duplicate of the test that replaced it — deleted), and one claimed to
cover the resolver's `enabled: false` short-circuit when the plugin layer returns before
the resolver is ever called. Renamed to state the half it actually pins; `vcs.test.ts`
is what covers the other half.

The sample verification docs quoted `bugsee-cli was terminated by signal SIGKILL` as
reproducible evidence — it no longer is — and claimed every `BugseePluginOptions` field
had been driven from a real build. The five VCS options have not been. Both say so now.

A HAZARD CHECKED AND RULED OUT, worth recording because the failure would have been
silent and total: `git diff --quiet` is stat-sensitive when the index is stale, and a CI
checkout or a restored build cache rewrites every mtime — if that read as dirty, the SHA
would be dropped on every CI build, killing the feature precisely where it matters most.
Measured on git 2.50.1: porcelain `git diff` refreshes the index first, so an mtime
rewrite AND a same-bytes rewrite with a new inode both read clean; only the plumbing
`git diff-index` needs an explicit `update-index --refresh`. Now pinned by a real-git
test so a future switch to plumbing cannot quietly reintroduce it.

Also corrected the cost claim, which was wrong on both count and scope: capture spawns
TWO subprocesses (vcs-metadata plus the git probe) per OUTPUT DIRECTORY, not one per
build — the code comment and the usage doc disagreed with each other and design §9.5.3.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
…te depended on

Round 3's code was correct — the first round to introduce no new functional defect.
What it introduced was three false claims, plus round 4 found a real hole none of the
first three rounds had spotted.

THE REAL ONE. Round 3 recorded the stale-index hazard as "measured and ruled out": a CI
checkout rewrites every mtime, and if `git diff --quiet` read that as dirty the SHA
would be dropped on every CI build — the feature silently dead exactly where it matters
most. The measurement was right, but it made the guarantee a property of the HOST'S
CONFIG rather than of the probe. The child inherits the ambient environment and so reads
~/.gitconfig, where `diff.autoRefreshIndex=false` restores the entire failure. The probe
now pins it for the single invocation. `diff.relative=false` is pinned alongside it,
closing the matching hole in the other direction: `diff.relative=true` scopes the diff to
the cwd's subtree, which would have quietly contradicted the whole-repository behaviour
both docs promise, and in a monorepo built from one package directory a dirty sibling
would have stopped counting. Both are now asserted directly, and by real-git tests that
write the hostile setting into the repository's own config — removing either pin fails
them, and so does restoring the pre-fix argv.

THE FALSE CLAIMS. Round 3's message and design §9.7 both recorded a test rename that is
not in the tree: the edit was an unasserted string replace whose anchor had already been
changed by round 2, so it silently did nothing while the documentation said it was done.
The test kept a name claiming to cover the resolver's `enabled: false` branch at a layer
that returns before the resolver is ever called — precisely the defect round 3 said it
was fixing, left in place and recorded as fixed. Renamed for real this time, with the
anchor asserted. The lesson is written into §9.8, because a no-op edit that reports
success is worse than one that fails.

Also: "Turning it on today costs one two short-lived subprocesses" — a botched edit in
the very user-facing line round 3 was correcting. And a comment pointing "below" at code
that sits above it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
…claimed docstring

Round 5 was the first round to find no correctness defect, no build-failure path and no
security regression. Two accuracy items, both real.

THE ONE THAT MATTERED. The test guarding the CI-checkout hazard rewrote a file's mtime to
NOW. Whether git sees a stat mismatch at all depends on whether it was built with
USE_NSEC: without it mtime is compared at SECOND granularity, and the whole test body
runs inside one second — so "now" matches the recorded stat and the test passes even with
the pin removed. On several Linux distro builds of git, the assertion protecting "a CI
checkout must not read as dirty" was therefore inert, while round 4's commit message
claimed removing either pin fails these tests. It now uses the epoch, which cannot match
at any granularity and cannot be racily-clean either, since the index's own recorded
mtime is untouched. Re-verified by mutation on this machine: removing either pin, or
flipping autoRefreshIndex to false, now fails.

The probe's docstring claimed to pin THE settings its answer depends on. It pins the two
most likely ones. `diff.ignoreSubmodules=all` also changes the answer — a submodule whose
checkout differs from the recorded gitlink reads clean, so a SHA gets recorded that does
not describe the built source — and so do the ambient GIT_DIR / GIT_WORK_TREE /
GIT_INDEX_FILE that git sets for every hook, which point a probe run from a pre-commit
hook at the commit-in-progress index. `ignoreSubmodules` is deliberately NOT pinned: a
third `-c` would not fix it, because per-submodule `submodule.<name>.ignore` outranks the
diff-level setting. Named in the docstring rather than papered over. Both remaining cases
fail toward recording a SHA, never toward breaking a build.

Also recorded, verified rather than assumed: the `-c` pins are safe on any git, because
`-c` keys are not validated against a registry — on a git predating `diff.relative`
(2.31) the pin is a silent no-op, not an error.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
… that "proved" it

Round 6 was meant to be the convergence check on round 5's two small edits. Verifying
them instead showed that two earlier rounds' conclusions were wrong — both because a
plausible claim about git was reasoned about carefully and never actually run.

Round 4 objected that the dirty probe's safety was a property of the HOST'S config,
because `diff.autoRefreshIndex=false` in ~/.gitconfig would make a CI checkout's
rewritten mtimes read as dirty and drop the SHA on every build. I accepted it and pinned
the setting. It is a no-op. Measured, content identical, mtime rewritten, git 2.50.1:

  git diff --quiet HEAD --        (porcelain, what we run)   0 clean at BOTH settings
  git diff-index --quiet HEAD --  (plumbing)                 1 dirty

Porcelain `git diff` compares CONTENT for a stat-unmatched entry regardless of the
setting, so it cannot affect this command. Round 3's original conclusion was right all
along, and for a better reason than it gave: the safety comes from using the PORCELAIN,
and a switch to `diff-index` would be the silent regression that costs every CI build its
SHA. That is now what the mtime test actually guards — flipping the argv to `diff-index`
fails it.

Worse than the useless pin was the evidence for it. Rounds 4 and 5 both recorded
"removing either pin fails these tests", and it was true — but only the literal argv
assertion ever failed, never a behavioural one, because the setting cannot change the
answer. A test that asserts an argv will always catch an argv change; that is not
evidence the argv matters. Every pin is now backed by a test that drives real git and
would fail on the BEHAVIOUR, with the argv assertion demoted to documentation.

And the hostile-config assertion was inert twice over: it re-applied the same mtime after
an earlier probe in the same test, and porcelain `git diff` rewrites the refreshed stat
into .git/index — so the stat simply matched. An assertion riding on the side effect of
the assertion above it. Deleted rather than repaired, since the setting is irrelevant.

What survives is real and now properly tested: `-c diff.relative=false`. Measured,
probing <repo>/pkg with a modified <repo>/a.txt exits 0 (clean) with the host's
`diff.relative=true` inherited and 1 (dirty) with the pin. In a monorepo built from one
package directory, a dirty sibling silently stopped counting — contradicting the
whole-repository behaviour both docs promise.

The lesson is written into design §9.10 because it generalises past this branch: a review
finding about tool behaviour is a HYPOTHESIS until it is run. Three rounds of careful
reasoning about `git diff` produced a no-op fix, a false doc claim and two assertions that
could not fail; one five-line shell script settled it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
`sourcemaps.e2e.ts` asserted the spawn sequence POSITIONALLY — `lines[0]` inject,
`lines[1]` upload — and the commit-SHA probe inserts a call ahead of both, so every index
shifted and the suite went red on CI.

The gate that would have caught it locally was never run: `pnpm ci:local` does NOT include
e2e, and this is a change whose entire observable effect is which subprocesses get spawned.
`--e2e` is the gate for it, and it is what this commit was verified with.

Fixed by asserting the sequence that now exists rather than loosening the check:

  1. vcs-metadata --working-dir <cwd>
  2. sourcemaps inject <outDir>
  3. debug-files upload … --version 1.2.3 --build 99

The probe being FIRST is the part worth pinning: the metadata it resolves is the build's
identity and has to exist before the upload that will eventually carry it.

Two assertions are deliberately about what does NOT happen:

- the upload argv is UNCHANGED by the probe. The metadata is collected and echoed on the
  result but is not on the wire yet — there is no field for it in the upload protocol
  (docs/design/source-maps.md §9). That assertion is what will fail, on purpose, the day one
  is added.
- the probe is NOT given the app token. It is a local git query needing no credentials, so
  handing them over would expose them to one more subprocess for nothing. I first asserted
  the opposite, assuming it inherited the environment; it does not, and the real behaviour is
  the better one.

Mutation-checked: defaulting `vcs` to false — the plausible way to lose the probe — fails
this test. `pnpm ci:local --e2e --force` green, on a branch now merged up to main.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
@krassx
krassx merged commit 69f0c8b into main Sep 6, 2026
2 checks passed
@krassx
krassx deleted the feat/bundler-commit-sha branch September 6, 2026 15:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant