NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages

Git::fake() swaps the manager for a recording GitFake — for the facade and for anything that injected GitManager — and returns it. Every driver it hands out is a seedable ProviderFake, no HTTP leaves the process (Http::preventStrayRequests()), and every call is recorded, whether it went through the flat driver methods, a repo() / pullRequest() / installations() handle, webhooks() or batch(). Seed what your code reads, run it, then assert:

use Illuminate\Support\Carbon;
use RoundlyConsulting\Git\Dto\Owner;
use RoundlyConsulting\Git\Dto\Repository;
use RoundlyConsulting\Git\Enums\ProviderName;
use RoundlyConsulting\Git\Facades\Git;

it('lists the connected repositories', function () {
    $fake = Git::fake();

    $fake->github()->seedRepositories([new Repository(
        provider: ProviderName::Github,
        id: '1',
        path: 'acme/api',
        name: 'api',
        description: null,
        defaultBranch: 'main',
        owner: new Owner(id: '1', name: 'acme', avatar: null),
        createdAt: Carbon::now(),
        lastActivityAt: Carbon::now(),
    )]);

    // run code that calls Git::github()->repositories() ...

    Git::assertSent(ProviderName::Github, 'repositories');
    Git::assertNotSent(ProviderName::Github, 'createRepository');
});

Assertions

Every assert names the driver method the call reached — the handles call the flat methods, so ->pullRequest(12)->merge() is recorded as mergePullRequest — and an assertSent() / assertNotSent() callback receives that method’s arguments positionally. Once Git::fake() ran, all of them work statically on the facade or on the returned fake:

// run code that calls Git::github()->repo('acme/app')->pullRequest(12)->merge() ...

Git::assertSent(ProviderName::Github, 'mergePullRequest');
Git::assertSent(ProviderName::Github, 'mergePullRequest', fn (string $path, int $number) => $number === 12);
Git::assertSentTimes(ProviderName::Github, 'approvePullRequest', 1);
Git::assertNotSent(ProviderName::Github, 'closePullRequest');
Git::assertNotSent(ProviderName::Github, 'mergePullRequest', fn (string $path) => $path === 'acme/billing');
Git::assertNothingSent(ProviderName::Gitlab);   // nothing reached GitLab
Git::assertNothingSent();                       // nothing reached any provider
Git::assertBatched(ProviderName::Github, 'languages');
Git::assertNotBatched(ProviderName::Github, 'repositories');
Git::assertRepositoryCreated('new-repo');
Git::assertRepositoryCreated('new-repo', owner: 'acme', template: 'acme/package-template');
Git::assertNoRepositoryCreated();
MethodPasses when
assertSent(provider, method, ?callback)The driver method was called — and, with a callback, one call’s arguments (passed positionally) satisfied it.
assertSentTimes(provider, method, times)The driver method was called exactly that many times.
assertNotSent(provider, method, ?callback)No call (matching the callback) happened.
assertNothingSent(?provider)No call at all — or none to that provider.
assertBatched(provider, method) / assertNotBatched(…)A batch()->{method}() was / was not issued.
assertRepositoryCreated(name, ?owner, ?template)A createRepository() matched the name — and the owner and template when given.
assertNoRepositoryCreated()No createRepository() call happened.
recorded(provider, ?method)Returns the list of RecordedCall (method, arguments), oldest first.
fakeFor(provider)Returns the one seedable ProviderFake for that provider.

Inspecting recorded calls

$calls = Git::recorded(ProviderName::Github, 'mergePullRequest'); // list<RecordedCall>

$calls[0]->method;     // "mergePullRequest"
$calls[0]->arguments;  // ['acme/app', 12, MergeMethod::Merge, null, null, null]

Git::recorded(ProviderName::Github);                  // every GitHub call, oldest first
Git::fakeFor(ProviderName::Gitlab)->seedRepositories([$repositoryDto]);

Injected managers

Because GitFake extends GitManager, a class that constructor-injects the manager is faked too — nothing to rebind:

use RoundlyConsulting\Git\Facades\Git;

it('merges the release pull request', function () {
    Git::fake()->github()->seedMergeCommit('abc123');

    $sha = app(ShipRelease::class)('acme/app', 12);   // constructor-injects GitManager

    expect($sha)->toBe('abc123');
    Git::assertSent(ProviderName::Github, 'mergePullRequest', fn (string $path, int $number) => $number === 12);
});

Unseeded behaviour

The double answers the whole Provider contract — every read, write, installation lookup and the commit query — so a host application never hits an undefined method as it grows. Three rules make its behaviour predictable:

Kind of callUnseeded behaviour
List reads (pullRequests, issues, tags, releases, contributors, branches, languages)An empty page — a real provider answer.
Single-resource reads (repository, commit, contents, issue, release, installation)Throws a RuntimeException naming the seeder to call.
Writes (createPullRequest, comment, mergePullRequest, …)Synthesized from the input — never throws.

Each fake driver supports exactly what its real driver supports: supports(), capabilities() and featureMatrix() answer the same, and an operation the forge lacks throws the same FeatureNotSupportedException before anything is recorded — a fake Bitbucket refuses ->pullRequest(1)->merge() just as Bitbucket does, so a test cannot pass against a flow production rejects.

Seeders

All chainable: seedRepositories, seedRepository, seedCreatedRepository, seedCommits, seedCommit, seedBranches, seedPullRequests, seedPullRequest, seedIssues, seedIssue, seedTags, seedReleases, seedRelease, seedContents, seedComparison, seedContributors, seedLanguages, seedSearchResults, seedComment, seedMergeCommit, seedApprovalState, seedSubmittedReview, seedPullRequestReviews, seedUser, seedInstallation, seedInstallations, seedWebhooks, seedCreatedWebhook, seedBatch.

use RoundlyConsulting\Git\Batch\BatchResult;

$fake->github()
    ->seedPullRequest($pullRequestDto)
    ->seedMergeCommit('abc123');

// ->pullRequest(n)->close() returns the seeded PR with state Closed; ->merge() returns 'abc123'

$fake->fakeFor(ProviderName::Gitlab)->seedRepositories([$repositoryDto]);

$fake->github()->seedBatch('languages', new BatchResult(['acme/api' => ['PHP' => 100]], []));
  • review() needs no seeding: it answers the state the event actually means (Comment → COMMENTED, never APPROVED), so a test asserting on the verdict cannot pass against a fake that would fail against GitHub; seedSubmittedReview() overrides it when a test needs a specific id or URL.
  • seedPullRequestReviews($reviews, $comments) seeds the two halves separately, because the real call reads them from two endpoints.
  • createRepository() shapes the returned Repository from its input — owner sets the path and Owner; defaultBranch, or main when autoInit or a template asked for a commit and empty otherwise, sets the default branch — unless seedCreatedRepository() is set.
  • Deliberate fallbacks: installationRepositories() reads the repositories bucket, installations()->all() stands in the single seeded installation, searchRepositories() falls back to the repositories bucket, pullRequest() / issue() / release() take the first of their list, and an unseeded pull request answers with the number you asked for.

GitHub App flows

Git::fake()->githubApp() returns the same GitHub double, so an install-and-verify flow runs end to end without HTTP:

$fake = Git::fake();
$fake->githubApp()->seedInstallation($installation)->seedRepositories([$repositoryDto]);

Git::githubApp()->installations()->forOrganization('acme-inc');
Git::github()->installationRepositories();

Git::assertSent(ProviderName::Github, 'organizationInstallation');

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.