From cbbc254b552adc8487772455ae0699f911fb2f3e Mon Sep 17 00:00:00 2001 From: Michael Aerni Date: Thu, 14 May 2026 12:00:03 +0200 Subject: [PATCH 1/2] Prevent overlapping CommitJob dispatches Mark CommitJob as ShouldBeUnique so concurrent dispatches collapse to a single queued job. Without this, multiple workers running CommitJobs against the same repo race on `git add` (producing `index.lock: File exists` errors) and on `git push` (producing non-fast-forward rejections because each worker's push uses a stale view of origin). The unique lock TTL is 120s, comfortably outlasting the default queue worker timeout so a hard worker crash recovers within 2 minutes. --- src/Git/CommitJob.php | 8 +++++++- tests/Git/GitTest.php | 12 ++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/Git/CommitJob.php b/src/Git/CommitJob.php index 726443d76b4..b09c2fb642e 100644 --- a/src/Git/CommitJob.php +++ b/src/Git/CommitJob.php @@ -3,15 +3,21 @@ namespace Statamic\Git; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Statamic\Facades\Git; -class CommitJob implements ShouldQueue +class CommitJob implements ShouldBeUnique, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable; + /** + * The number of seconds after which the job's unique lock will be released. + */ + public int $uniqueFor = 120; + /** * Create a new job instance. */ diff --git a/tests/Git/GitTest.php b/tests/Git/GitTest.php index 38c1a15b3f9..00e1b86854f 100644 --- a/tests/Git/GitTest.php +++ b/tests/Git/GitTest.php @@ -400,6 +400,18 @@ public function it_dispatches_commit_job() Queue::assertPushed(\Statamic\Git\CommitJob::class, 1); } + #[Test] + public function it_only_dispatches_one_commit_job_at_a_time() + { + Queue::fake(); + + Git::dispatchCommit(); + Git::dispatchCommit(); + Git::dispatchCommit(); + + Queue::assertPushed(\Statamic\Git\CommitJob::class, 1); + } + #[Test] public function it_doesnt_push_by_default() { From a5a9662171e4fc101878d3f5e3b91209a7c18efb Mon Sep 17 00:00:00 2001 From: Michael Aerni Date: Thu, 14 May 2026 12:01:37 +0200 Subject: [PATCH 2/2] Attribute coalesced commits to the configured user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When multiple saves are dispatched within a single CommitJob's lock window, ShouldBeUnique drops the duplicate dispatches and the queued job's `git add` sweeps up everyone's changes — but the queued job still carries the first dispatcher's user. Attributing a multi-author commit to a single user is misleading. Track dispatch attempts in cache via Cache::increment in dispatchCommit, then in CommitJob::handle null out the committer when the pull reveals more than one dispatch occurred. Git's existing fallback then uses the configured user.name / user.email (the bot account) — consistent with how scheduler-driven saves are already attributed. --- src/Git/CommitJob.php | 5 ++++- src/Git/Git.php | 3 +++ tests/Git/GitTest.php | 27 +++++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/Git/CommitJob.php b/src/Git/CommitJob.php index b09c2fb642e..8d5a651060f 100644 --- a/src/Git/CommitJob.php +++ b/src/Git/CommitJob.php @@ -7,6 +7,7 @@ use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Support\Facades\Cache; use Statamic\Facades\Git; class CommitJob implements ShouldBeUnique, ShouldQueue @@ -30,6 +31,8 @@ public function __construct(public $message = null, public $committer = null) */ public function handle() { - Git::as($this->committer)->commit($this->message); + $committer = Cache::pull('statamic-git-pending-saves', 0) > 1 ? null : $this->committer; + + Git::as($committer)->commit($this->message); } } diff --git a/src/Git/Git.php b/src/Git/Git.php index 473ce1e6ea0..368fd653964 100644 --- a/src/Git/Git.php +++ b/src/Git/Git.php @@ -4,6 +4,7 @@ use Illuminate\Filesystem\Filesystem; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Cache; use Statamic\Console\Processes\Git as GitProcess; use Statamic\Contracts\Auth\User as UserContract; use Statamic\Facades\Antlers; @@ -93,6 +94,8 @@ public function dispatchCommit($message = null) $message = null; } + Cache::increment('statamic-git-pending-saves'); + CommitJob::dispatch($message, $this->authenticatedUser()) ->onConnection(config('statamic.git.queue_connection')) ->delay($delayInMinutes ?? null); diff --git a/tests/Git/GitTest.php b/tests/Git/GitTest.php index 00e1b86854f..f6cf140e892 100644 --- a/tests/Git/GitTest.php +++ b/tests/Git/GitTest.php @@ -3,6 +3,7 @@ namespace Tests\Git; use Illuminate\Filesystem\Filesystem; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Queue; use PHPUnit\Framework\Attributes\Test; use Statamic\Console\Processes\Git as GitProcess; @@ -412,6 +413,32 @@ public function it_only_dispatches_one_commit_job_at_a_time() Queue::assertPushed(\Statamic\Git\CommitJob::class, 1); } + #[Test] + public function it_attributes_coalesced_commits_to_the_configured_user() + { + Cache::put('statamic-git-pending-saves', 3); + + $user = User::make()->email('alice@example.com'); + + Git::shouldReceive('as')->with(null)->andReturnSelf()->once(); + Git::shouldReceive('commit')->with('Entry saved')->once(); + + (new \Statamic\Git\CommitJob('Entry saved', $user))->handle(); + } + + #[Test] + public function it_attributes_non_coalesced_commits_to_the_authenticated_user() + { + Cache::put('statamic-git-pending-saves', 1); + + $user = User::make()->email('alice@example.com'); + + Git::shouldReceive('as')->with($user)->andReturnSelf()->once(); + Git::shouldReceive('commit')->with('Entry saved')->once(); + + (new \Statamic\Git\CommitJob('Entry saved', $user))->handle(); + } + #[Test] public function it_doesnt_push_by_default() {