jkudish / graft
Git manager and platform provider for Laravel — facades, typed DTOs, test fakes, and scoped repositories.
Requires
- php: ^8.2
- illuminate/http: ^11.0 || ^12.0 || ^13.0
- illuminate/support: ^11.0 || ^12.0 || ^13.0
- nesbot/carbon: ^3.0
- symfony/process: ^7.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/ai: ^0.3
- laravel/pint: ^1.0
- orchestra/testbench: ^10.0
- pestphp/pest: ^4.0
Suggests
- laravel/ai: Required to use the optional AI tools in Graft\Ai\Tools (^0.3)
README
Graft
Branch, commit, and ship from your Laravel app — without ever shelling out by hand or hand-rolling the GitHub API.
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$repoPaththrough every method, andowner/repois 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()andGitHub::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
Gitfacade — branches, commits, index, remotes, merge, rebase, cherry-pick, tags, stash, worktrees, blame, clean.GitHubfacade — pull requests, issues, reviews, comments, CI status, labels, repository info.- Scoped repository —
Git::repo($path)binds both surfaces to a single repo and auto-detectsowner/repofrom the origin remote. - Typed DTOs —
Branch,Commit,Status,MergeResult,Stash,Worktree,PullRequest,Issue,Review,CheckRun,CiStatus, and more — all readonly, all with named properties. - Recording fakes —
Git::fake()andGitHub::fake()swap the real implementations for in-memory recorders with semantic assertions and configurable return values / exceptions. - Errors with context —
MergeConflictExceptionexposes the conflicting files;PlatformExceptionexposes the status code and the response body.
Requirements
- PHP 8.2+
- Laravel 11, 12, or 13
gitbinary onPATH
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 — the recommended entry point
- Git Facade — local repository operations
- GitHub Facade — platform operations
- AI Tools — ready-to-use tools for the Laravel AI SDK
- Active Objects — methods on PRs and Issues
- Authentication — one token for the API and git subprocesses
- Testing —
Git::fake()andGitHub::fake() - Error Handling
- Configuration
- DTOs
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:
- The GitHub API client.
Bearerauth on every HTTP call — no surprises. gitsubprocesses over HTTPS. Anything that reaches a remote (Git::clone,Git::fetch,Git::pull,Git::push,Git::addWorktreeon 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/configis roughly the same threat surface as.env, with one wrinkle:.envis typically640(or stricter) on Forge-style deploys, while.git/configis whatever your umask produces (often644, sometimes more permissive). On shared hosts where local users matter, lock down.git/configpermissions or usemode=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/v3 → https://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:
bakedmode never relies on env at all — the token is in.git/config.envmode passesGRAFT_GITHUB_TOKENtoProcessvia the explicit$envarray, which Symfony forwards to the child regardless of$_ENVstate.
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.
