cypresslab/gitelephant
GitElephant is a PHP abstraction layer for managing Git repositories. It wraps the git binary in an object-oriented API, letting you inspect history, branches, tags, commits, and files without handling raw command output. Requires PHP 7.2+ and Git on a Unix-like system.
GitElephant v5 is a maintenance/modernization release with breaking platform requirements — no new Git features, but your Laravel app must satisfy:
| Requirement | v4.6 | v5.0.0 |
|---|---|---|
| PHP | ^7.4 || ^8.0 |
>=8.1 |
| Symfony (Process) | <6.x range |
^6.4 || ^7.0 || ^8.0 |
| Autoloading | psr-0 (deprecated) |
psr-4 |
| PHPUnit (contributors) | 9.x | ^10.5 || ^11.0 |
| Tooling | — | Rector ^2.0, PHPStan ^2.0, ECS ^12 || ^13; annotations → PHP 8 attributes |
Practical impact for Laravel teams: Laravel 10/11/12 apps are fine. Laravel 9 apps pinned to PHP 8.0 or Symfony 6.0–6.3 will fail composer update — stay on matteosister/git-elephant:^4.6 or bump PHP first.
Installation (no Laravel auto-discovery — this is a framework-agnostic library):
composer require matteosister/git-elephant:^5.0
Namespace is GitElephant\ (unchanged by the PSR-4 switch — class names are stable, only the file layout inside vendor/ moved).
Wire it into the container yourself. Repository is stateful (it holds a working directory and a Caller), so bind it explicitly:
// app/Providers/GitServiceProvider.php
use GitElephant\Repository;
public function register(): void
{
$this->app->scoped(Repository::class, fn () => Repository::open(base_path()));
}
Use scoped() rather than singleton() if you run Octane — a singleton would leak the repo path and last command output between requests.
First use case — a "repo health" panel:
use GitElephant\Repository;
class RepoStatusController
{
public function __invoke(Repository $repo)
{
$status = $repo->getStatus();
$head = $repo->getCommit();
$branch = $repo->getBranch();
return view('git.status', [
'branch' => $branch->getName(),
'modified' => $status->modified(), // array of StatusFile
'untracked'=> $status->untracked(),
'lastMsg' => $head->getMessage(),
'lastDate' => $head->getDatetimeAuthor(),
]);
}
}
Repository aroundBecause every method shells out to the git binary, one service class gives you a single place for binary path, timeouts, and error handling:
namespace App\Services;
use GitElephant\Repository;
class GitService
{
public function __construct(private Repository $repo) {}
public function sync(string $remote = 'origin', string $branch = 'main'): void
{
$this->repo->fetch($remote);
$this->repo->checkout($branch);
$this->repo->pull($remote, $branch);
}
}
$repo = Repository::open('/var/www/app');
// Status / staging / commit
$repo->getStatus()->all(); // StatusFile[]
$repo->stage('app/Models/User.php'); // or stage(['a.php', 'b.php'])
$repo->unstage('app/Models/User.php');
$repo->commit('Update user model');
// Branches
$branches = $repo->getBranches(); // Branch[]
$repo->createBranch('feature/x', 'main');
$repo->checkout('feature/x');
$repo->merge('main');
// History
foreach ($repo->getLog('main', null, 20) as $commit) {
$commit->getSha();
$commit->getMessage();
$commit->getDiff(); // Diff object
}
// Tags
$repo->createTag('v1.2.3', 'Release 1.2.3');
$repo->getTags();
$repo->deleteTag('v1.2.3');
// Remotes
$repo->addRemote('origin', '[email protected]:acme/app.git');
$repo->fetch('origin');
$repo->push('origin', 'main');
storage_path()use GitElephant\Repository;
$path = storage__path('repos/'.$id);
Repository::cloneFrom('https://github.com/acme/app.git', $path);
$repo = Repository::open($path);
class DeployCommand extends Command
{
protected $signature = 'deploy:pull {branch=main}';
public function handle(GitService $git): int
{
try {
$git->sync('origin', $this->argument('branch'));
$this->info('Synced.');
return self::SUCCESS;
} catch (\Throwable $e) {
$this->error($e->getMessage());
return self::FAILURE;
}
}
}
Clones and fetches over the network belong on a queue with an explicit timeout — Symfony Process defaults to 60s and will kill large clones:
class SyncRepository implements ShouldQueue
{
public $timeout = 900;
public $tries = 3;
public $backoff = [30, 120, 300];
public function __construct(public string $path, public string $branch = 'main') {}
public function handle(): void
{
Repository::open($this->path)->pull('origin', $this->branch);
}
public function middleware(): array
{
return [(new WithoutOverlapping($this->path))->releaseAfter(60)];
}
}
For anything not covered (worktree, submodule, reset --hard), drop to the Caller:
$caller = $repo->getCaller();
$caller->execute('reset --hard origin/main');
$caller->getOutput();
composer why-not matteosister/git-elephant 5.0 first. If it reports symfony/process at 6.0–6.3, you're blocked until you upgrade the framework. symfony/*:^8.0 additionally requires PHP 8.4+ — don't add it explicitly, let Laravel's constraint resolve.psr-0 autoload block mapping GitElephant\ in your own composer.json, (b) class_alias'd internal classes, or (c) monkey-patched by requiring files from the vendor tree by path. Move to psr-4 mapping or drop it — Composer handles it now.php artisan optimize:clear after upgrading so no cached composer classmap from the old PSR-0 layout survives.Binary path. The git binary must exist in $PATH for the PHP-FPM/queue user (usually not your shell user). Override it:
$repo->getCaller()->setBinaryPath('/usr/bin/git');
Debug with which git as the same user, e.g. sudo -u www-data which git.
Symfony Process 7/8 timeout behaviour. Long clones/fetches exceed the 60s default. Set a job $timeout and a process timeout; otherwise you get a ProcessTimedOutException mid-clone and a half-populated working directory.
Paths must be absolute and writable. Repository::open() on a relative path silently resolves against the process CWD, which differs between web and queue contexts. Always use base_path() / storage_path().
Credentials. HTTPS: use a token in the URL or a credential helper — never interactive prompts (they hang until timeout). SSH: the key must be readable by the runtime user:
sudo -u www-data ssh -T [email protected]
Detached HEAD. Checking out a SHA leaves you detached; subsequent pull() calls fail confusingly. Always follow with a branch checkout, or prefer fetch + reset --hard.
Shallow clones. Repository::cloneFrom() doesn't expose --depth. If you need it, use the caller: $repo->getCaller()->execute("clone --depth=1 {$url} {$dir}").
Don't put Repository in a singleton — see the Octane note above. Also don't inject it into queued job constructors (the object isn't serializable across the wire); resolve it inside handle().
Validate branch/tag names coming from request input. These end up in a shell command string; a ; or $(...) in a branch name is a command injection vector.
Caller — most failures are visible here before you touch anything else:
try {
$repo->pull('origin', 'main');
} catch (\Throwable $e) {
\Log::error('git failed', [
'error' => method_exists($repo->getCaller(), 'getError') ? $repo->getCaller()->getError() : null,
'output' => $repo->getCaller()->getOutput(),
]);
throw $e;
}
RuntimeException and package-specific exceptions), not boolean returns.$repo->getStatus() is the cheapest smoke test; if it throws, your path or binary is wrong.GitElephant\Command\Command / extend the base command classes and register it on the Caller. Keeps escaping consistent instead of hand-built strings.Caller for one-offs — $repo->getCaller()->execute(...) with getOutput() / getError() is the intended escape hatch.Repository objects — build a RepositoryFactory that caches one instance per path within a request/job, rather than binding a single global.config('git.binary') and config('git.timeout') into the Caller in a service provider, so deploys can tune without code changes.Use a throwaway directory per test (sys_get_temp_dir()), git init it via the library, and assert on getStatus()/getLog(). Avoid sharing one fixture repo across parallel test processes — GitElephant has no locking, and index.lock errors will make your suite flaky.
How can I help you explore Laravel packages today?