Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Git State Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

Minimal Setup

composer require --dev sebastian/git-state

Install as --dev first. The library shells out to git under the hood, so if you promote it to a production dependency you are committing every production host to having a .git directory and a git binary on PATH — a decision you should make deliberately, not by accident.

Look at These Three Things First

The whole API surface is one class and one value object:

  • SebastianBergmann\GitState\Builder::build() — returns a state object, or false
  • SebastianBergmann\GitState\Builder::commit() — new in 1.1.0; returns the checked-out commit hash, or false. Works without an origin remote.
  • SebastianBergmann\GitState\GitState — originUrl(), branch(), commit(), isClean(), status()

There is no service provider, no facade, no config file. If you want Laravel-flavoured ergonomics you build that thin wrapper yourself (see below). Read Builder.php — it is short enough that reading it is faster than reading docs, and it tells you exactly which git subcommands run and what happens on failure. Note that Builder::commit() and GitState::commit() are different things: the former is a cheap one-shot you can call without build(), the latter is the value already resolved inside a successful build().

Your First Use Case

Start with a guard clause on the operation that hurts most when it goes wrong: a pre-deploy or pre-migration check.

use SebastianBergmann\GitState\Builder;

$state = (new Builder)->build();

if ($state === false) {
    $this->error('Not a Git checkout, or no "origin" remote configured.');
    return self::FAILURE;
}

if (! $state->isClean()) {
    $this->error("Working tree is dirty:\n" . $state->status());
    return self::FAILURE;
}

$this->info(sprintf('Ready: %s@%s (%s)', $state->branch(), substr($state->commit(), 0, 8), $state->originUrl()));

Run it from php artisan tinker before you build anything around it — a one-liner in Tinker tells you immediately whether the library can read your checkout at all, which is the failure mode you will hit first. If all you need is "what commit is checked out?", call (new Builder)->commit() instead: it is a single subprocess and does not care whether an origin remote exists.

Implementation Patterns

Wrap It Once, Inject It Everywhere

Builder is a concrete class with no interface, so it is awkward to mock. Put it behind your own boundary on day one:

namespace App\Support;

use SebastianBergmann\GitState\Builder;
use SebastianBergmann\GitState\GitState;

interface GitStateReader
{
    public function current(): ?GitState;

    public function commit(): ?string;
}

final class NativeGitStateReader implements GitStateReader
{
    public function current(): ?GitState
    {
        return (new Builder)->build() ?: null;
    }

    public function commit(): ?string
    {
        return (new Builder)->commit() ?: null;
    }
}

Bind it in AppServiceProvider, and your commands, middleware and services all depend on GitStateReader. Tests bind a fake; production binds the real thing. This costs ten minutes and saves you from ever calling new Builder scattered across the codebase.

Important: convert the package's false into null at this boundary. Everywhere downstream gets to assume "state or nothing" rather than "state or a magic falsy object", and PHP's type system works with you instead of against it. The split between current() and commit() is what lets you keep the build stamp working in checkouts that have no origin remote.

Commit-Only Checks Without an origin

Before 1.1.0 the only way to get a commit hash was build()->commit(), which silently failed on any checkout without an origin remote — local forks, vendored copies, some submodule layouts, scratch clones. Now you can decouple the two concerns:

$builder = new Builder;

$commit = $builder->commit();          // works with no origin
$state  = $builder->build();           // still needs origin for branch/clean/url

Use this deliberately: branch, clean and originUrl() still come from build() and still require origin. If your only requirement is traceability (a stamp, a log line, a cache key), reach for commit() and stop worrying about remotes. It is also measurably cheaper — one subprocess instead of the several build() spawns.

Artisan Command as the Canonical Entry Point

The natural Laravel shape is a dedicated command that CI calls and queued jobs delegate to:

final class VerifyGitState extends Command
{
    protected $signature = 'git:verify
                            {--branch=* : Allowed branch patterns}
                            {--require-clean : Fail on a dirty working tree}';

    protected $description = 'Validate the Git checkout before deployment';

    public function handle(GitStateReader $reader): int
    {
        $state = $reader->current();

        if ($state === null) {
            $this->error('No usable Git state.');
            return self::FAILURE;
        }

        if ($this->option('require-clean') && ! $state->isClean()) {
            $this->error($state->status());
            return self::FAILURE;
        }

        $patterns = $this->option('branch');

        if ($patterns !== [] && ! Str::is($patterns, $state->branch())) {
            $this->error("Branch {$state->branch()} is not allowed (expected: " . implode(', ', $patterns) . ')');
            return self::FAILURE;
        }

        return self::SUCCESS;
    }
}

Str::is() accepts wildcards, so --branch='release/*' --branch=main gives you a readable CI policy without writing a matcher.

Version-Aware Runtime Behaviour

Two patterns that pay off repeatedly:

Health/debug endpoint. Expose the commit and branch on an internal-only route so a postmortem can answer "what exactly was running?" in one request. Use commit() so the endpoint still reports something useful when the checkout has no origin:

Route::get('/_meta', function (GitStateReader $r) {
    $state = $r->current();

    return response()->json([
        'commit' => $state?->commit() ?? $r->commit(),
        'branch' => $state?->branch(),
        'clean'  => $state?->isClean(),
    ])->setStatusCode($state || $r->commit() ? 200 : 503);
})->middleware(['auth', 'can:view-ops']);

Build stamp in the UI. Render substr($state->commit(), 0, 8) into a Blade partial or response header for staging environments. It ends the "did the deploy actually land?" conversation instantly. Here GitStateReader::commit() is the better call — one subprocess, no origin requirement.

Cache It — But Not Naively

Each build() call spawns multiple git subprocesses. That is cheap once, wasteful in a middleware that runs per request. Cache the derived values, not the object:

$meta = Cache::remember('git-state', now()->addMinutes(5), function () {
    $builder = new Builder;
    $state   = $builder->build();

    return [
        'commit' => $state?->commit() ?? $builder->commit(),
        'branch' => $state?->branch(),
        'origin' => $state?->originUrl(),
        'clean'  => $state?->isClean(),
    ];
});

Deliberately skip status() from the cached payload — it is arbitrary multi-line output, can be large, and is only interesting at the moment you are already failing. Note the ?? $builder->commit() fallback: since commit() is the only value available without origin, it is the one field worth caching even when build() came back false.

Long TTLs are correct here: a commit hash does not change inside a running request cycle, and in production it does not change at all between deploys. In local development use a very short TTL, or skip the cache entirely, because you commit constantly and a stale hash will confuse you.

CI Integration

- name: Validate Git state
  run: php artisan git:verify --require-clean --branch='main' --branch='release/*'

Note that in CI the checkout usually is clean, so --require-clean mostly catches stray build artefacts generated by earlier steps — which is exactly the class of bug that produces "works in CI, broken tag" releases. If you only want to record the SHA (for tags, artifacts, or deployment markers), skip the whole command and use php -r 'echo (new SebastianBergmann\GitState\Builder)->commit();' — no origin needed, one subprocess.

Queue It If It Is on a Hot Path

If a check is advisory rather than blocking, dispatch a job that records the state, rather than making the user wait on a subprocess:

RecordGitState::dispatch();

Anything that blocks on git status in a large repository belongs off the request path.

Gotchas and Tips

false Is the Whole Error Model — and There Are Now Two of Them

build() returns false for both "not a Git repository" and "no origin remote". commit() returns false only for the former (and general git failure). You still cannot distinguish the causes from a single return value, and the docs are explicit about it.

The new method gives you a free disambiguation trick:

$builder = new Builder;
$state   = $builder->build();

if ($state === false) {
    $isRepo = $builder->commit() !== false;
    // $isRepo === true  -> repo exists, "origin" remote is missing
    // $isRepo === false -> not a Git checkout at all
}

That is worth encoding in your error messages rather than listing both possibilities, which is what the README example does. For most purposes, though, treat false as "cannot determine state".

Do not assume it throws. It does not. Every call site needs an explicit === false / null branch, and forgetting one gives you a silent skip rather than a loud failure — the worst possible outcome for a deployment guard.

Deploy Layouts Will Break This

This is the big one. Both build() and commit() read .git from the working directory. 1.1.0 solves the missing remote problem, not the missing repository problem. Many deployment strategies do not ship .git:

  • git archive / tarball-based deploys
  • Envoyer/Deployer-style atomic releases that only copy the working tree
  • Docker images built with a .dockerignore excluding .git (the default in most projects)
  • Some serverless and ephemeral build environments

In all of these build() and commit() both return false in production while working in staging, and the mismatch is discovered at the worst moment. Decide up front: either bake the commit SHA into an env var at build time (GIT_COMMIT=$(git rev-parse HEAD)) and prefer that in production, or accept that this library is a development/CI tool only. The latter is usually right, and it is why --dev is the sane default install.

Container Permissions

git refuses to operate on a repository owned by a different user than the one running the command (detected dubious ownership), which is extremely common when a Docker build copies .git as root and runs PHP as www-data. Remedy with git config --global --add safe.directory /app in the image, or by chowning the checkout. Symptom: build() and commit() return false inside the container while working fine on the host. The new method does not change this — it is the same underlying git invocation.

No Timeouts, No Injection

Builder has no configuration — no timeout, no path argument, no binary override. On a huge repository, git status can take seconds. You cannot tune that from the package; your only levers are caching, queueing, or bypassing the package with Process::timeout(5)->run('git status --porcelain'). commit() is the cheap escape hatch: it runs a single, fast subcommand and is safe to call more liberally than build().

status() Is Only Meaningful When Dirty

The README implies it, but be explicit in your code: call status() only inside the ! isClean() branch. Doing it unconditionally means running the extra subprocess on every check and logging empty strings. Also remember status() is raw git status output — multi-line, untrusted-ish, and not something to interpolate into HTML without escaping.

originUrl() Is Not a Normalised URL

You get back whatever Git has configured, which is typically the SSH form ([email protected]:org/repo.git), not https://github.com/org/repo. If you intend to compare against a configured URL or link to a web UI, normalise it yourself — do not Str::contains($config['repo'], $state->originUrl()) and expect it to match.

Testing

Because Builder is concrete, test through your GitStateReader abstraction. Have the fake return null for the "not a repo" case and a real GitState otherwise — construct one directly if the constructor is public, or simply have the fake expose the same primitive getters. Add a branch to the fake for commit() returning a hash while current() returns null; that is the new state combination 1.1.0 makes reachable, and the one most likely to be untested.

Then your command tests cover the branches that actually matter (dirty, wrong branch, no repo, no origin) without depending on git being installed in the CI test image.

Add one integration test that shells out for real, marked @group integration, so a Git upgrade that changes status output format gets caught rather than surprising you in production.

Upgrade Path

1.1.0 is a purely additive minor release: one new static-less method, no signature changes, no deprecations. build() and GitState behave exactly as before, so upgrading from 1.0.x is a composer update with no code changes required.

The package is tiny and stable, and you should expect no churn. Pin it in composer.json and let Dependabot handle bumps; there is nothing here to configure and therefore nothing to migrate. If the maintainer ever stops, ~100 lines of Symfony\Component\Process is a viable fork — keep that in mind as a de-risking note rather than a plan.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
htinlynn/genius-helper
maco-studios/hephaestus
elmogy/larafast
xeviant/laravel-paystack
gorilladash/laravel-website-sdk
martialbe/flysystem-oss
chondal/3312client
jeroen-g/flickr
opscale-co/nova-genbi
itsjustvita/laravel-retention
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log
gregsphotography/site-builder-components
io-digital/clickatell
snowsoft/laravel-admin
oxygen/auth
relicsoftware/barakah-payment-gateway
tobya/work-with-composer
equipc/laravel-email-validator