Skip to content

fix(git): discriminated provider outcome + one boundary log + unified absence policy (2l6)#96

Merged
brandon-fryslie merged 1 commit into
mainfrom
brandon-git-segment-2l6_discriminated-outcome
Jun 10, 2026
Merged

fix(git): discriminated provider outcome + one boundary log + unified absence policy (2l6)#96
brandon-fryslie merged 1 commit into
mainfrom
brandon-git-segment-2l6_discriminated-outcome

Conversation

@brandon-fryslie

Copy link
Copy Markdown
Contributor

Summary

Implements sheriff law-remediation ticket brandon-git-segment-2l6 (findings #2 + #8, folded): git/cache providers swallowed ~10 failure paths with bare catches — two actively changing data meaning (stash failure → 0, repoName failure → path.basename) — while projectGitInfo coerced absence to ''/0 against MetricsPayload's preserve-absence rule, and render-payload's "provider rejections are logged by the underlying data-provider layer" comment was false for exactly these two providers.

The shape

  • Outcome<T> = ok | absent | failed(reason) (src/utils/outcome.ts) — "this value is unknown because the fetch failed" is now representable, distinct from a real 0/''. [LAW:types-are-the-program]
  • execGitAsync returns the LaunchResult instead of flattening launch's typed termination cause into a thrown Error; one classifier maps it per command, with "is a non-zero exit a domain answer?" passed as data ("absent" | "failed"). No upstream / no tags / no remote → absent; timeout / spawn-error / signal → failed. [LAW:dataflow-not-control-flow]
  • One log site per consumption surface [LAW:single-enforcer] [LAW:effects-at-boundaries]: buildRenderPayload logs the pull path (via deps.log = dlog); GitDataProvider's subscribe delivery fold logs the push path. The interior never logs. projectGitInfo is a pure fold returning {git?, failures[]} — the boundary performs the effect.
  • Unified absence policy [LAW:one-type-per-behavior]: GitPayload fields are all optional like MetricsPayload; absent and failed both flow as MISSING fields into applyInput's default + last_error chain. The coerce-to-''/0 shim is deleted.
  • readTail/cacheExpiresAt distinguish ENOENT (absent, the everyday fresh-session case) from real read errors (failed).
  • repoName's basename rule survives only as the explicit no-remote policy, never as an error fallback.

Verification

  • pnpm typecheck, pnpm lint, pnpm check:protocol clean; targeted suites (11 files incl. 2 new) pass — 200+ tests.
  • New contract tests: test/git-service-outcomes.test.ts (real-git classification: ok(0) stashes vs absent upstream vs ok(basename) policy) and test/render-payload-outcomes.test.ts (boundary: failed → missing field + exactly one warn log; absent → missing field + no log).
  • test/git-worktree.test.ts rewritten against real worktrees — its exec mock was dead since the launch migration and only passed through the old swallowing path.
  • Live against an isolated daemon: healthy repo renders with zero warnings; a corrupt .git produces exactly one warn line (provider fetch failed: git status --porcelain -b: non-zero, exit 128, fatal: not a git repository…) while the bar still renders.

Ticket: brandon-git-segment-2l6 · Epic: brandon-law-remediation-58c

… absence policy (2l6)

Sheriff findings #2+#8: git/cache providers swallowed ~10 failure paths
(stash failure rendered as 0, repoName failure as basename, failed status
as a clean fallback branch) and projectGitInfo coerced absence to ''/0
while MetricsPayload preserved it — two opposite absence rules in one file,
with render-payload's "providers log their own rejections" comment false
for exactly these two providers.

[LAW:types-are-the-program] New Outcome<T> = ok | absent | failed(reason)
(src/utils/outcome.ts). execGitAsync returns the LaunchResult instead of
flattening it to a throw; one classifier maps it per command — a non-zero
exit that is the domain answering "there is none" (no upstream, no tags,
no remote) is absent; transport failures are failed. GitInfo's on-demand
fields are Outcome-typed; getGitInfo returns Outcome<GitInfo>; the
basename repoName survives only as the explicit no-remote policy, never
as an error fallback. readTail/cacheExpiresAt distinguish ENOENT (absent)
from real read errors (failed).

[LAW:effects-at-boundaries][LAW:single-enforcer] Failure flows as a value
to each consumption surface's edge and is logged exactly once there:
buildRenderPayload (pull path, via deps.log=dlog) and GitDataProvider's
subscribe delivery fold. projectGitInfo is now a pure fold returning
{git?, failures[]}; absent and failed both project as MISSING payload
fields into applyInput's default + last_error chain.

[LAW:one-type-per-behavior] GitPayload adopts MetricsPayload's
preserve-absence policy (all fields optional); the coerce-to-''/0 shim is
gone, so "no stashes" and "stash count unknown" are no longer the same
rendered 0.

Verified live against an isolated daemon: healthy repo renders with zero
warnings (absent logs nothing), a corrupt .git produces exactly one warn
line with the real git error while the bar still renders.

Ticket: brandon-git-segment-2l6 (epic brandon-law-remediation-58c)

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Z.ai Coding Agent Review

This PR introduces a discriminated Outcome<T> (ok | absent | failed) across the git and cache provider stack, replacing T | null and catch-and-swallow patterns. The refactoring is well-executed and consistent with the laws cited inline.

No must-change items found. Notable design points worth the author's awareness (all pre-existing or intentional, none newly introduced):

  • Double-logging surface: A coalesced fetch whose failure is consumed by both the subscribe path (deliverable logs at LINE 328) and the render path (buildRenderPayload logs at LINE 416) will produce two warn lines for the same underlying failure. The comments explicitly acknowledge these as two distinct consumption paths with independent diagnostic purposes, so this is by design — but worth noting for anyone grepping logs.

  • as casts for ABSENT: Promise.resolve(ABSENT) as Promise<Outcome<GitInfo>> at render-payload.ts:379 and :406 is needed because ABSENT is typed Outcome<never>. The cast is sound (absent is a valid Outcome<T> for any T) but could alternatively be Promise.resolve<Outcome<GitInfo>>(ABSENT) to avoid the raw as.

  • Unconditional aheadBehind: getAheadBehindAsync always runs even when ahead/behind data isn't needed by the caller (pre-existing; the old code ran it unconditionally too). If this ever becomes a latency concern, a showAheadBehind opt-in flag would be the seam.

The test coverage is strong — new real-git integration tests (git-service-outcomes.test.ts, render-payload-outcomes.test.ts), converted worktree tests from mocks to real repos, and existing cache/git tests updated for the new type.

✅ Approved

@brandon-fryslie brandon-fryslie merged commit 4ff0240 into main Jun 10, 2026
7 checks passed
@brandon-fryslie brandon-fryslie deleted the brandon-git-segment-2l6_discriminated-outcome branch June 10, 2026 07:49
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.

2 participants