sebastian/git-state
sebastian/git-state is a small PHP library for describing the state of a Git checkout. Its Builder returns the origin remote URL, current branch, commit hash, and working directory status, or false when the directory is not a repository.
Architecture fit. sebastian/git-state is not a Laravel package — there is no service provider, config file, facade, or published assets. It is a single-purpose, dependency-free PHP library with two entry points: Builder::build(), which returns either a state object (originUrl(), branch(), commit(), isClean(), status()) or false, and — new in 1.1.0 — Builder::commit(), which returns the checked-out commit hash (or false) without requiring an origin remote. Architecturally this is still a utility, not infrastructure: it fits cleanly as an injected collaborator behind a small application-owned adapter (interface + DTO), used by Artisan commands, deployment gates, and CI scripts. The new commit() entry point is meaningful for us because it decouples the most commonly needed datum (the SHA) from the heaviest and most environment-sensitive part of the library (remote resolution and working-tree status). It does not belong in the HTTP request path, in domain services, or anywhere a container may lack a .git directory.
Integration feasibility. Trivial mechanically and unchanged by this release: one composer require, one class, no transitive dependencies (composer show --tree will show a leaf), no autoload surprises. 1.1.0 is an additive minor with no declared breaking changes, so the upgrade from 1.0.0 is a caret-range no-op for any consumer already pinned at ^1.0. The real feasibility questions remain environmental, not code-level:
| Check | Why it matters | Verdict |
|---|---|---|
| PHP floor | Sebastian Bergmann packages track current PHP aggressively; must confirm 1.1.0 did not raise the floor | Must verify against our runtime before adopting; a floor above our fleet is a hard stop |
git binary present |
Library shells out; FPM/Alpine/Slim images frequently omit git | Blocker for runtime use; fine for CI runners |
.git directory present |
Most production artifacts are built from a tarball or docker build context excluding .git |
Both build() and commit() return false in the exact environments we care most about |
exec/shell_exec enabled |
Common hardening in shared hosting / hardened containers | Silent breakage risk |
| Git ≥ 2.35 "dubious ownership" | Containers running as non-owner UID hit safe.directory errors |
Real operational gotcha |
origin remote configured |
Previously required by build(); commit() removes this dependency |
Partially retired as a blocker — see below |
Technical risk.
build() returning false conflated "not a Git repository" with "no origin remote." With Builder::commit() available independently of origin, the adapter can now disambiguate positively: if commit() yields a hash, the repository exists and only the remote is missing. The residual ambiguity is in the negative direction — commit() returning false still covers "no .git", "git binary missing", and "exec disabled" identically. Net: our adapter needs to call both methods to classify, which is one extra subprocess but removes the single worst debugging trap in the original assessment.build() semantics. The release note says commit() works "without requiring an origin remote," which strongly implies build() still returns false when no origin exists. If so, existing call sites that use build() for branch/clean checks will still fail in remote-less checkouts (common in vendor forks, air-gapped mirrors, and some CI mirrors). This must be confirmed from source before we write the adapter, because it determines whether build() is safe as a general-purpose entry point.commit() is a single rev-parse HEAD and is comparatively cheap, but status() on a large monorepo can still take seconds and the API exposes no timeout knob. That call remains unacceptable in any user-facing latency path; the argument for keeping commit() out of the request path is now weaker but not absent.status() (raw porcelain, no --porcelain=v2, detached-HEAD branch semantics undocumented) and for originUrl() (single remote). commit() sidesteps parsing entirely for the SHA, which is the field we are most likely to expose.Key questions for the team.
build() still return false when no origin remote exists, or did 1.1.0 relax that too? This determines whether build() is a safe general entry point or only a CI-with-remote entry point..git present in production, or is this strictly a CI/deploy-time tool?exec()-based Git calls or laravel/git in the codebase? If so, is standardizing worth a dependency?commit()-only stamping is now a viable middle path)?Stack fit. Works alongside Laravel 10/11/12 as a plain library. Recommended shape, revised for 1.1.0:
app/Git/GitStateProvider.php — interface (commit(): ?string, state(): GitState|Unavailable)app/Git/PackageGitStateProvider.php — adapter wrapping Builder, using commit() as the cheap, remote-independent probe and build() only when branch/clean/remote are actually needed; translates false into a typed Unknown/NotAvailable result rather than nullable ambiguityapp/Git/GitState.php — readonly DTO (branch, commit, originUrl, clean, availability)GitStateServiceProvider — singleton binding, config-driven enable/disable flagThis gives us one testable seam, keeps the third-party type out of application code, and makes the fork/vendor swap a one-file change. The adapter's commit() path should be the default resolution strategy; build() is the escalation when more context is required.
Migration path.
^1.0 → ^1.1 for any existing consumer; additive minor, no code change expected. Verify in CI that no build() call site regressed.composer require --dev sebastian/git-state for CI helpers and test-time version stamping. Zero production blast radius.exec('git rev-parse HEAD') scattered in scripts or Artisan commands folds into the adapter — and now maps 1:1 onto Builder::commit(), which is a cleaner substitution than routing through build().APP_COMMIT/APP_VERSION env var; promote the package to a runtime dependency only where the SHA must reflect the actual checkout on disk. commit() makes this narrower runtime use case materially safer than it was at 1.0.0.Compatibility.
actions/checkout runs with fetch-depth: 1 but the working tree intact. commit() reads HEAD only, so shallow clones are sufficient; repositories checked out without an origin remote (mirrors, vendored forks) are now usable for SHA extraction but still not for build()-derived branch/remote data unless question 2 resolves favourably.Sequencing (4–6 weeks, low effort — largely unchanged, with phase 4 simplified).
| Phase | Deliverable | Exit criteria |
|---|---|---|
| 1 (wk 1) | Spike: adapter + Artisan git:state command |
Works locally, on CI, and correctly reports unavailability when .git absent; correctly distinguishes "no origin" from "no repo" using commit() |
| 2 (wk 1–2) | CI guardrail: fail on dirty tree / wrong branch | Enforced as a required status check on protected branches; team agrees on policy |
| 3 (wk 3) | Deployment gate in release pipeline | Rehearsed rollback if check mis-fires; documented override |
| 4 (wk 4–5) | /version or health metadata endpoint (cached), backed by commit() only |
P95 latency impact < 2ms via cache; no status() call on the request path; endpoint documented |
| 5 (wk 6) | Runbook + ADR ("why this over laravel/git", "why commit() over build()") |
On-call can triage the common failures unaided |
Maintenance. Ongoing cost is dominated not by the package (frozen-size, infrequent releases, no transitive dependency surface) but by governance: a single-maintainer dependency in a production manifest. Controls: caret pin, automated dependency PRs, and a standing decision that if the maintainer archives or breaks us, we vendor the ~100 LOC under our namespace within a sprint. The artifact that will actually accrue maintenance is our adapter and its tests — budget a few hours per PHP minor upgrade. 1.1.0 adds no maintenance surface; it removes a workaround we would otherwise have written by hand.
Support. Support load remains concentrated in misdiagnosis of the ambiguous false return, but the surface has shrunk. Required runbook entries:
git --version in the runtime container; neither build() nor commit() gives a clear signal here.false; the adapter must disambiguate before logging.commit() still succeeds, build() does not. Route to commit()-only extraction rather than treating this as a failure.safe.directory blanket trust.Instrumentation: log at info on success with commit/branch, warning on false with the reason (now including a distinct "remote absent, commit available" reason code), error only on unexpected exceptions. Dashboards should alert on repeated false at deploy time (indicates artifact/pipeline regression), not on individual occurrences.
Scaling. Every call is a blocking subprocess spawn. The two entry points now have materially different cost profiles:
commit() — single rev-parse, roughly 5–15 ms, no working-tree scan, no network-adjacent remote resolution. Affordable once per process; still not per-request by default.build() / status() — can reach seconds on large trees. Never on a per-request path.git and .git are absent and design the "unknown" path as the default, not the exception.Failure modes.
| Mode | Detection | Impact | Mitigation |
|---|---|---|---|
git binary missing |
false from both methods |
Runtime endpoint degrades | Fail closed at deploy gate; fail open ("unknown") at runtime |
No .git in artifact |
false from both methods |
Silent loss of metadata | Build-arg injection fallback |
No origin remote |
commit() succeeds, build() returns false |
Previously indistinguishable from "no repo"; now diagnosable | Use commit() for SHA; skip branch/remote claims |
exec disabled |
PHP warning/exception | Hard failure | Detect at boot, disable feature flag |
| Repo ownership mismatch | Git exit error | false on an otherwise valid checkout |
Fix container UID; document |
Large repo status() latency |
Latency spike | Request timeout if mis-placed | Never call in request path; cache |
Ambiguous false (negative case) |
Log noise, wrong alerts | Debugging time | Adapter-level disambiguation; reason codes |
| Upstream PHP floor bump | Composer resolution failure | Blocked upgrade | Dependabot + fork contingency |
| Maintainer abandonment | Stale releases | Low, given size | Vendor/fork plan pre-approved |
Ramp-up. A competent Laravel developer is productive in under an hour — the API is now five getters, a boolean, and a standalone commit(). What does need ramp-up is the judgment layer:
(new Builder)->commit() and (new Builder)->build()->branch()), plus the adapter walkthrough.commit() for version stamping and existence checks, build() only when branch, remote, or cleanliness is genuinely required..git isn't guaranteed.Recommendation. Adopt narrowly and deliberately, and treat 1.1.0 as a modest strengthening of the case: as a dev/CI dependency for deployment guardrails and version stamping, fronted by a thin application adapter that uses commit() as its default probe, with a pre-approved fork path and a build-time metadata fallback for production. The new method removes the "no origin remote" ambiguity that was the single largest diagnostic liability in the 1.0.0 assessment, and it opens a defensible narrow runtime use case (internal admin/version endpoints where .git is guaranteed) without exposing us to status() latency. Do not adopt as a general runtime dependency in request-handling containers, and confirm the build()-without-origin semantics before treating it as a general-purpose entry point. On that basis the risk is low, the value (blocking dirty-tree and wrong-branch deploys, plus reliable SHA stamping) is concrete and immediately measurable, and the total cost of reversal remains trivial.
How can I help you explore Laravel packages today?