Skip to content

[6.x] Prevent overlapping git commit jobs#14672

Merged
jasonvarga merged 6 commits into
statamic:6.xfrom
aerni:fix/unique-commit-job
Jun 10, 2026
Merged

[6.x] Prevent overlapping git commit jobs#14672
jasonvarga merged 6 commits into
statamic:6.xfrom
aerni:fix/unique-commit-job

Conversation

@aerni

@aerni aerni commented May 14, 2026

Copy link
Copy Markdown
Contributor

Closes #11322

Issue

Concurrent CommitJobs running against the same .git/ directory cause two related failures:

  • fatal: Unable to create '.git/index.lock': File exists. (two git add / commit processes racing on git's index mutex)
  • error: failed to push some refs ... non-fast-forward (each worker pushing with a stale view of origin, so the second push fails git's atomic ref CAS)

Both root-cause to the same thing: multiple queue workers running CommitJobs concurrently against one repo.

Solution

Mark CommitJob as ShouldBeUnique so duplicate dispatches are dropped at the dispatch layer. The first dispatch wins; its git add {{ paths }} sweeps up any changes from saves that landed during the lock window. No two git processes from CommitJob ever run simultaneously.

uniqueFor is set to 120 seconds. In normal operation the lock is released the moment handle() returns; uniqueFor is only the crash-safety net for SIGKILL / OOM scenarios where Laravel's release-on-completion code never runs. 120s comfortably outlasts the default Laravel queue worker timeout (60s), so a hard crash recovers within 2 minutes.

Attribution

Coalescing changes the attribution story: a burst of saves now produces one commit, and naively that commit gets attributed to whichever user happened to dispatch first. Other users' changes are in the commit but their name isn't on it.

The second commit in this PR addresses that with a small counter pattern:

  • Git::dispatchCommit() calls Cache::increment('statamic-git-pending-saves') on every attempt (including dispatches that will be dropped).
  • CommitJob::handle() does Cache::pull(...) at the start. If the count is greater than 1, the committer is replaced with null.
  • Git's existing fallback in gitUserName() / gitUserEmail() then attributes the commit to the configured user.name / user.email (the bot account).

This is the same fallback Statamic already uses today for scheduler-driven and CLI-driven saves where there's no authenticated user, so we're extending an existing pattern, not introducing a new one.

Michael Aerni and others added 6 commits May 14, 2026 12:02
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.
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.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When multiple users save during the dispatch delay window, their
individual dispatches are coalesced into a single CommitJob. Rather
than silently attributing that commit to the configured bot user,
this records every contributor as a Co-Authored-By trailer so the
commit log reflects who actually made changes.

dispatchCommit() now appends {name, email} to a cache array instead
of incrementing a counter. CommitJob::handle() pulls that array,
deduplicates by email, and when more than one unique author is found
it: (a) commits as the bot user, and (b) appends Co-Authored-By
lines to the message before handing it to Git::commit().

shellEscape() is updated from escapeshellcmd() to targeted escaping
of only \, $, and ` — the three characters that retain special
meaning inside a double-quoted shell string per the Bash Reference
Manual §3.1.2.3. escapeshellcmd() was over-escaping characters like
<, >, ;, and newlines that are literal inside "...", which would
corrupt the Co-Authored-By trailer format and was also producing
spurious backslashes in git log for ordinary user names and messages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tests the full dispatchCommit → cache accumulation → CommitJob::handle
→ real git commit path. Two users dispatch; the second is dropped by
ShouldBeUnique but their save is still recorded in the cache array.
Running handle() directly then verifies both Co-Authored-By trailers
appear in the git log.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@jasonvarga

Copy link
Copy Markdown
Member

Made a few additions on top, some flagged by AI review.

Configurable lock expiryuniqueFor is now driven by statamic.git.unique_lock_expiry (env: STATAMIC_GIT_UNIQUE_LOCK_EXPIRY, default 120s) so operators with slow queues or large repos can tune it without subclassing the job.

Co-Authored-By trailers instead of bot fallback — rather than silently attributing a coalesced commit to the configured bot user, the pending-saves tracker now stores {name, email} per dispatch. When more than one unique author is found at run time, the commit message gets Co-Authored-By: trailers for every contributor. Single-user saves are still attributed to that user directly. The bot user is only used as the primary author when saves from multiple people are coalesced.

shellEscape() fixescapeshellcmd() was over-escaping characters like <, >, ;, #, and newlines that are literal inside a double-quoted shell string, which would have mangled the Co-Authored-By: Name <email> format (and was also producing spurious backslashes in git log for ordinary user names). Replaced with targeted escaping of only \, $, and ` — the three characters that actually have special meaning inside "..." per the Bash Reference Manual §3.1.2.3.

Additional tests — deduplication of repeated saves by the same user, a real git integration test for the trailer format, and an end-to-end pipeline test that runs through dispatchCommit() → cache accumulation → handle()git log.

@jasonvarga jasonvarga enabled auto-merge (squash) June 10, 2026 19:28
@jasonvarga jasonvarga merged commit 2649964 into statamic:6.x Jun 10, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Auto-commit throwing many errors

2 participants