jkudish/graft

Git manager and platform provider for Laravel — facades, typed DTOs, test fakes, and scoped repositories.

Maintainers

Package info

github.com/jkudish/graft

pkg:composer/jkudish/graft

Statistics

Installs: 51

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.3.0 2026-05-10 01:05 UTC

This package is auto-updated.

Last update: 2026-05-10 01:17:44 UTC


README

Graft — Git and GitHub for Laravel

Graft

Branch, commit, and ship from your Laravel app — without ever shelling out by hand or hand-rolling the GitHub API.

Latest Version Tests Code Quality Total Downloads License PHP Version

Graft is the missing git-and-platform layer for Laravel. It puts everything you'd reach for exec('git ...') or a hand-rolled GitHub HTTP client to do behind two clean facades — Git and GitHub — and returns typed, readonly DTOs instead of raw output or arrays.

It was built for the kind of app that needs to do real work in real repos: tools that orchestrate AI coding agents, dashboards that open and merge PRs on your behalf, internal automation that branches, commits, and ships. It scales from "create a tag" to "spin up a worktree, run a series of changes, open a PR, request review, watch CI, merge, and clean up" — without you ever leaving Laravel idioms.

Three things make it pleasant:

  • Git::repo($path) scopes both git and platform calls to a single repository — no more threading $repoPath through every method, and owner/repo is auto-detected from the origin remote.
  • Active objects. $pr->merge(), $pr->requestReview([...]), $issue->close() — DTOs returned from the platform carry their actions with them.
  • Tests that read like specs. Git::fake() and GitHub::fake() return recording fakes with semantic assertions (assertBranchCreated, assertPrCreated, assertReviewRequested) — no Mockery boilerplate, no string-matching command lines.
use Graft\Facades\Git;

$repo = Git::repo('/path/to/project');

$repo->checkout('feature/payments', create: true);
$repo->add('.');
$repo->commit('Add Stripe webhook handler');
$repo->push(setUpstream: true);

$pr = $repo->createPullRequest(
    title: 'Add Stripe webhook handler',
    body: 'Closes #142',
    head: 'feature/payments',
    base: 'main',
);

$pr->requestReview(['teammate']);
$pr->addLabels(['enhancement']);

What's in the box

  • Git facade — branches, commits, index, remotes, merge, rebase, cherry-pick, tags, stash, worktrees, blame, clean.
  • GitHub facade — pull requests, issues, reviews, comments, CI status, labels, repository info.
  • Scoped repositoryGit::repo($path) binds both surfaces to a single repo and auto-detects owner/repo from the origin remote.
  • Typed DTOsBranch, Commit, Status, MergeResult, Stash, Worktree, PullRequest, Issue, Review, CheckRun, CiStatus, and more — all readonly, all with named properties.
  • Recording fakesGit::fake() and GitHub::fake() swap the real implementations for in-memory recorders with semantic assertions and configurable return values / exceptions.
  • Errors with contextMergeConflictException exposes the conflicting files; PlatformException exposes the status code and the response body.

Requirements

  • PHP 8.2+
  • Laravel 11, 12, or 13
  • git binary on PATH

Installation

composer require jkudish/graft

Optionally publish the config:

php artisan vendor:publish --tag=graft-config

Set your GitHub token in .env:

GITHUB_TOKEN=ghp_your_token_here

That single token powers both the GitHub API client and git's HTTPS auth — see Authentication for the details, including the config:cache gotcha that bites every Forge deploy.

Table of Contents

Scoped Repository

Git::repo($path) returns a ScopedRepository that binds both git and platform operations to a single path. It's the most ergonomic way to use Graft.

$repo = Git::repo('/path/to/project');

$repo->currentBranch();
$repo->checkout('feature/x', create: true);
$repo->add('.');
$repo->commit('Changes');
$repo->push(setUpstream: true);

$pr     = $repo->createPullRequest(title: 'Feature X', body: '...', head: 'feature/x', base: 'main');
$issues = $repo->listIssues();
$ci     = $repo->getCiStatus('feature/x');

The scoped repository detects owner/repo from the origin remote URL (HTTPS or SSH) and resolves the configured platform provider automatically.

Git Facade

The Git facade proxies all methods on GitManager. Each method takes $repoPath as its first argument — or use Git::repo($path) to drop it entirely.

Branches

Git::branches($path);              // Collection<Branch>
Git::currentBranch($path);         // "main"
Git::branchExists($path, 'dev');

Git::createBranch($path, 'feature/x', 'main');
Git::checkout($path, 'feature/x');
Git::deleteBranch($path, 'feature/x', force: true);

Commits and Index

Git::add($path, ['src/File.php']);
Git::add($path);                                         // stage everything

$commit = Git::commit($path, 'Fix the thing');
// Commit { hash, shortHash, message, author, email, date, parents }

Git::log($path, limit: 5);                               // Collection<Commit>
Git::show($path);                                        // HEAD commit
Git::status($path);                                      // Status { staged, unstaged, untracked }
Git::diff($path, staged: true);                          // string

Remotes and Syncing

Git::fetch($path, prune: true);
Git::pull($path, 'origin', 'main');
Git::push($path, 'origin', 'main', setUpstream: true);

Git::remotes($path);                                     // Collection<Remote>
Git::addRemote($path, 'upstream', 'https://github.com/org/repo.git');
Git::removeRemote($path, 'upstream');

Merge, Rebase, Cherry-pick

$result = Git::merge($path, 'feature/x', noFf: true);
// MergeResult { success, message, conflicts }

Git::rebase($path, 'main');
Git::cherryPick($path, ['abc123', 'def456']);

Git::mergeAbort($path);
Git::rebaseAbort($path);
Git::cherryPickAbort($path);

Tags, Stash, Worktrees

Git::tags($path);                                        // Collection<string>
Git::createTag($path, 'v1.0.0', message: 'Release 1.0');
Git::deleteTag($path, 'v1.0.0');

Git::stash($path, message: 'WIP', includeUntracked: true);
Git::stashPop($path);
Git::stashList($path);                                   // Collection<Stash>

$wt = Git::addWorktree($path, '/tmp/worktree', 'feature/x', createBranch: true);
Git::listWorktrees($path);                               // Collection<Worktree>
Git::removeWorktree($path, '/tmp/worktree', force: true);

Repository and Config

Git::init('/path/to/new-repo');
Git::clone('https://github.com/org/repo.git', '/path/to/dest', branch: 'main');
Git::isRepository('/some/path');

Git::getConfig($path, 'user.name');
Git::setConfig($path, 'user.name', 'Graft');

Git::blame($path, 'src/File.php');                       // Collection<Blame>
Git::clean($path, directories: true);

GitHub Facade

The GitHub facade works with any repository by passing owner/repo directly.

Pull Requests

$pr  = GitHub::createPullRequest('owner/repo', 'Title', 'Body', 'feature', 'main', draft: true);
$pr  = GitHub::getPullRequest('owner/repo', 42);
$prs = GitHub::listPullRequests('owner/repo', state: 'open');

GitHub::updatePullRequest('owner/repo', 42, ['title' => 'New title']);
GitHub::mergePullRequest('owner/repo', 42, method: 'squash');
GitHub::closePullRequest('owner/repo', 42);

Issues

$issue = GitHub::createIssue('owner/repo', 'Bug', 'Details', labels: ['bug']);
$issue = GitHub::getIssue('owner/repo', 10);

GitHub::listIssues('owner/repo', state: 'open');
GitHub::updateIssue('owner/repo', 10, ['state' => 'closed']);

Reviews, Comments, CI, Labels

GitHub::requestReview('owner/repo', 42, ['reviewer1']);
GitHub::listReviews('owner/repo', 42);                   // Collection<Review>

GitHub::addComment('owner/repo', 42, 'Looks good!');
GitHub::addReviewComment('owner/repo', 42, 'Nit', 'abc123', 'src/File.php', 15);

GitHub::getCiStatus('owner/repo', 'abc123');             // CiStatus { state, checkRuns }
GitHub::listCheckRuns('owner/repo', 'abc123');

GitHub::addLabels('owner/repo', 42, ['ready-for-review']);
GitHub::removeLabel('owner/repo', 42, 'wip');

Repository Info

GitHub::getRepository('owner/repo');
// Repository { name, fullName, description, defaultBranch, private, url }

AI Tools (Laravel AI SDK)

Graft ships nine ready-to-use tools for the Laravel AI SDK. Each tool implements both Laravel\Ai\Contracts\Tool (so the SDK can call it) and Graft\Ai\Contracts\IdentifiableTool (so you can discover, route, or expose it via MCP using a stable category:action ID).

Tool toolId() What it does
GitLogTool graft:git:log Recent commit history for a repo
GitStatusTool graft:git:status Working tree status (staged / unstaged / untracked)
GitDiffTool graft:git:diff Diff for the working tree or a single file
GitBranchesTool graft:git:branches List branches and the current branch
GitHubListPrsTool graft:github:list-prs List pull requests for owner/repo
GitHubGetIssueTool graft:github:get-issue Fetch a single issue by number
GitHubCreateIssueTool graft:github:create-issue Create a new issue
GitHubListIssuesTool graft:github:list-issues List issues for owner/repo
GitHubPrReviewTool graft:github:pr-review Add a review (approve, request changes, comment)

Register them with an Agent like any other Laravel AI tool:

use Laravel\Ai\Agent;
use Laravel\Ai\Promptable;
use Graft\Ai\Tools\GitLogTool;
use Graft\Ai\Tools\GitStatusTool;
use Graft\Ai\Tools\GitHubListPrsTool;

class ReleaseManager extends Agent
{
    use Promptable;

    public function instructions(): string
    {
        return 'You help draft release notes from git history and open PRs.';
    }

    public function tools(): array
    {
        return [
            GitLogTool::class,
            GitStatusTool::class,
            GitHubListPrsTool::class,
        ];
    }
}

Or use them directly without an agent — they're plain classes:

use Graft\Ai\Tools\GitLogTool;
use Laravel\Ai\Tools\Request;

$tool = new GitLogTool;
$json = $tool->handle(new Request(['repo_path' => '/path/to/repo', 'limit' => 5]));

Requires the optional laravel/ai dependency:

composer require laravel/ai

The tools call into the Git and GitHub facades under the hood, so Git::fake() and GitHub::fake() work exactly the same way for testing them.

Active Objects

PullRequest and Issue DTOs returned from the platform provider carry a reference back to the provider so you can act on them directly.

$pr = GitHub::getPullRequest('owner/repo', 42);

$pr->merge(method: 'squash');
$pr->close();
$pr->update(['title' => 'Updated']);
$pr->requestReview(['teammate']);
$pr->addComment('Ship it!');
$pr->addReviewComment('Fix this', 'abc123', 'src/File.php', 10);
$pr->getCiStatus();
$pr->addLabels(['approved']);

$issue = GitHub::getIssue('owner/repo', 10);

$issue->close();
$issue->update(['title' => 'Updated']);
$issue->addComment('Fixed in #42');
$issue->addLabels(['resolved']);

Authentication

Graft uses a single token — GITHUB_TOKEN — for two distinct things:

  1. The GitHub API client. Bearer auth on every HTTP call — no surprises.
  2. git subprocesses over HTTPS. Anything that reaches a remote (Git::clone, Git::fetch, Git::pull, Git::push, Git::addWorktree on a private parent) needs the same token to authenticate.

Graft handles both for you. When you call Git::init, Git::clone, or Git::addWorktree, it writes a host-scoped credential helper to the repo's .git/config so subsequent git operations authenticate without further setup. For Git::clone specifically, it also injects the helper as ephemeral git config (via the GIT_CONFIG_COUNT/GIT_CONFIG_KEY_*/GIT_CONFIG_VALUE_* env vars git supports since 2.31) so the clone itself can authenticate — without that bootstrap, the persisted helper would only take effect after the clone has already needed credentials.

The two modes

'git_credentials' => [
    'enabled'  => env('GRAFT_GIT_CREDENTIALS_ENABLED', true),
    'mode'     => env('GRAFT_GIT_CREDENTIALS_MODE', 'baked'),
    'username' => env('GRAFT_GIT_CREDENTIALS_USERNAME', 'x-access-token'),
    'host'     => env('GRAFT_GIT_CREDENTIALS_HOST'), // null = derive from base_url
],
Mode What lands in .git/config Token at rest? Best for
baked The literal token, inside a per-host credential helper snippet Yes Production servers, especially behind config:cache (default)
env A ${GRAFT_GITHUB_TOKEN} placeholder; Graft injects the var into every git subprocess's env No Environments where you don't want secrets in .git/config

About baked-mode threat surface. Token-at-rest in .git/config is roughly the same threat surface as .env, with one wrinkle: .env is typically 640 (or stricter) on Forge-style deploys, while .git/config is whatever your umask produces (often 644, sometimes more permissive). On shared hosts where local users matter, lock down .git/config permissions or use mode=env.

Hosts and GitHub Enterprise

The credential host is auto-derived from base_url by stripping a leading api. — that covers github.com (api.github.com → github.com) and the canonical GHE pattern (https://github.example.com/api/v3https://github.example.com). Self-hosted setups that don't follow either pattern should set GRAFT_GIT_CREDENTIALS_HOST explicitly.

If you've already configured a per-host credential helper at the same key (e.g. via gh auth setup-git), Graft's installation will overwrite it on the next init / clone / addWorktree. Set enabled=false to keep your existing setup.

The config:cache gotcha

This is the bug that motivated the unified token feature, and it's worth knowing about even if you never look at the implementation.

Laravel's config:cache skips LoadEnvironmentVariables on subsequent boots. After it runs, your .env values still reach config() (because they were captured when the cache was built), but $_ENV and $_SERVER are empty. Symfony Process inherits its child env from $_ENV + $_SERVER (not getenv_all), so a credential helper that does password=$GITHUB_TOKEN finds nothing and git fetch fails with Authentication failed.

Graft sidesteps this in both modes:

  • baked mode never relies on env at all — the token is in .git/config.
  • env mode passes GRAFT_GITHUB_TOKEN to Process via the explicit $env array, which Symfony forwards to the child regardless of $_ENV state.

If you've ever shipped an app to Forge with a hand-rolled credential helper script, this is the failure mode you hit. The default baked mode makes it impossible.

Opting out

Set GRAFT_GIT_CREDENTIALS_ENABLED=false to skip credential installation entirely. Graft's other behavior is unchanged. You're then responsible for git auth — typically a global gh auth setup-git, a system credential helper, or your own per-repo helper.

Testing

Both facades have fake() methods that swap in a recording fake with semantic assertions.

use Graft\Facades\Git;
use Graft\Facades\GitHub;

it('creates a feature branch, opens a PR, and requests review', function () {
    $git = Git::fake();
    $github = GitHub::fake();

    // ...your code under test...

    $git->assertBranchCreated('feature/x');
    $git->assertCommitted('Add feature');
    $git->assertPushed('feature/x');

    $github->assertPrCreated('Add feature');
    $github->assertReviewRequested(['teammate']);
    $github->assertLabelsAdded(['enhancement']);
});

Git Assertions

// Generic
$fake->assertCalled('commit');
$fake->assertCalled('commit', fn ($args) => str_contains($args[1], 'fix'));
$fake->assertNotCalled('push');
$fake->assertCalledTimes('fetch', 2);

// Semantic
$fake->assertBranchCreated('name');
$fake->assertCheckedOut('branch');
$fake->assertCommitted('message substring');
$fake->assertPushed('branch');
$fake->assertPulled();
$fake->assertFetched();
$fake->assertMerged('branch');
$fake->assertTagCreated('v1.0.0');
$fake->assertCloned('https://...');
$fake->assertInitialized('/path');
$fake->assertWorktreeAdded('/path');
$fake->assertWorktreeRemoved('/path');
$fake->assertStashed();

// Negative
$fake->assertNothingPushed();
$fake->assertNothingCommitted();
$fake->assertNothingCalled();

GitHub Assertions

$fake->assertPrCreated('title');
$fake->assertPrMerged(42);
$fake->assertPrClosed(42);
$fake->assertIssueCreated('title');
$fake->assertIssueClosed(10);
$fake->assertCommentAdded('body substring');
$fake->assertLabelsAdded(['label1']);
$fake->assertReviewRequested(['reviewer1']);
$fake->assertNothingCalled();

Configuring Return Values and Errors

$fake = Git::fake();
$fake->shouldReturn('currentBranch', 'develop');
$fake->shouldReturn('status', new Status(staged: ['file.php'], unstaged: [], untracked: []));
$fake->shouldThrow('push', new ProcessException('Remote rejected'));

Error Handling

RuntimeException
├── GitException                 // base for all git errors (command + stderr context)
│   ├── ProcessException         // git process failed
│   ├── BranchException          // branch operation failed
│   ├── MergeConflictException   // exposes conflicts: list<string>
│   ├── WorktreeException
│   └── TagException
└── PlatformException            // exposes statusCode + response
use Graft\Exceptions\MergeConflictException;
use Graft\Exceptions\PlatformException;

try {
    Git::merge($path, 'feature/x');
} catch (MergeConflictException $e) {
    $e->conflicts;                // list<string>
    Git::mergeAbort($path);
}

try {
    GitHub::mergePullRequest('owner/repo', 42);
} catch (PlatformException $e) {
    $e->statusCode;               // 409
    $e->response;                 // ['message' => 'Pull request is not mergeable']
}

Configuration

Published to config/graft.php:

return [
    'git_binary' => env('GRAFT_GIT_BINARY', 'git'),
    'timeout'    => env('GRAFT_TIMEOUT', 60),

    'platform' => [
        'default'   => env('GRAFT_PLATFORM', 'github'),
        'providers' => [
            'github' => [
                'token'    => env('GITHUB_TOKEN'),
                'base_url' => env('GITHUB_API_URL', 'https://api.github.com'),

                'git_credentials' => [
                    'enabled'  => env('GRAFT_GIT_CREDENTIALS_ENABLED', true),
                    'mode'     => env('GRAFT_GIT_CREDENTIALS_MODE', 'baked'),
                    'username' => env('GRAFT_GIT_CREDENTIALS_USERNAME', 'x-access-token'),
                    'host'     => env('GRAFT_GIT_CREDENTIALS_HOST'),
                ],
            ],
        ],
    ],
];
Variable Default Description
GITHUB_TOKEN (required) GitHub personal access token (used for both API and git HTTPS auth)
GRAFT_GIT_BINARY git Path to the git binary
GRAFT_TIMEOUT 60 Timeout in seconds for git commands
GRAFT_PLATFORM github Default platform provider
GITHUB_API_URL https://api.github.com GitHub API base URL (for GitHub Enterprise)
GRAFT_GIT_CREDENTIALS_ENABLED true Auto-install a host-scoped credential helper on init/clone/worktree
GRAFT_GIT_CREDENTIALS_MODE baked baked (token in .git/config) or env (token via GRAFT_GITHUB_TOKEN)
GRAFT_GIT_CREDENTIALS_USERNAME x-access-token Username sent to the helper (PATs ignore it; GitHub Apps need this)
GRAFT_GIT_CREDENTIALS_HOST (derived) Override the credential host (e.g. https://github.example.com)

Data Transfer Objects

All DTOs are readonly classes with typed properties.

Git: Branch, Commit, Status, Remote, MergeResult, Stash, Worktree, Blame

Platform: PullRequest (active), Issue (active), Comment, Review, CheckRun, CiStatus, Repository

Contributing

PRs welcome. Run the suite before pushing:

composer test         # unit + feature
composer test:all     # includes integration (requires real git)
composer phpstan      # level 8
composer lint         # Pint

License

Graft is open-sourced software licensed under the MIT license.