Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions config/git.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,20 @@

'dispatch_delay' => env('STATAMIC_GIT_DISPATCH_DELAY', 0),

/*
|--------------------------------------------------------------------------
| Unique Lock Expiry
|--------------------------------------------------------------------------
|
| When commits are queued, a unique lock prevents multiple jobs from
| running concurrently against the same repository. This value (in
| seconds) controls how long that lock is held as a crash-safety
| net. It should exceed your queue worker's configured timeout.
|
*/

'unique_lock_expiry' => env('STATAMIC_GIT_UNIQUE_LOCK_EXPIRY', 120),

/*
|--------------------------------------------------------------------------
| Git User
Expand Down
22 changes: 20 additions & 2 deletions src/Git/CommitJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,45 @@
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 Illuminate\Support\Facades\Cache;
use Statamic\Facades\Git;

class CommitJob implements ShouldQueue
use function Statamic\trans as __;

class CommitJob implements ShouldBeUnique, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable;

public int $uniqueFor;

/**
* Create a new job instance.
*/
public function __construct(public $message = null, public $committer = null)
{
$this->uniqueFor = config('statamic.git.unique_lock_expiry', 120);
}

/**
* Execute the job.
*/
public function handle()
{
Git::as($this->committer)->commit($this->message);
$saves = Cache::pull('statamic-git-pending-saves', []);
$users = collect($saves)->unique('email')->values();
$coalesced = $users->count() > 1;

$message = $this->message;

if ($coalesced) {
$trailers = $users->map(fn ($u) => "Co-Authored-By: {$u['name']} <{$u['email']}>")->implode("\n");
$message = ($message ?? __('Content saved'))."\n\n".$trailers;
}

Git::as($coalesced ? null : $this->committer)->commit($message);
}
}
10 changes: 9 additions & 1 deletion src/Git/Git.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -93,6 +94,10 @@ public function dispatchCommit($message = null)
$message = null;
}

$saves = Cache::get('statamic-git-pending-saves', []);
$saves[] = ['name' => $this->gitUserName(), 'email' => $this->gitUserEmail()];
Cache::put('statamic-git-pending-saves', $saves);

CommitJob::dispatch($message, $this->authenticatedUser())
->onConnection(config('statamic.git.queue_connection'))
->delay($delayInMinutes ?? null);
Expand Down Expand Up @@ -283,8 +288,11 @@ protected function shellEscape(string $string)
{
$string = str_replace('"', '', $string);
$string = str_replace("'", '', $string);
$string = str_replace('\\', '\\\\', $string);
$string = str_replace('$', '\\$', $string);
$string = str_replace('`', '\\`', $string);

return escapeshellcmd($string);
return $string;
}

/**
Expand Down
119 changes: 112 additions & 7 deletions tests/Git/GitTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -252,13 +253,8 @@ public function it_shell_escapes_git_user_name_and_email()

Git::commit('Message"; echo "deleting all your files now"; #');

$expectedUser = 'Jimmy\; echo deleting all your files now\; \# <jimmy@haxor.org\; echo deleting all your files now\; \#>';
$expectedMessage = 'Message\; echo deleting all your files now\; \#';

if (static::isRunningWindows()) {
$expectedUser = str_replace('\\', '^', $expectedUser);
$expectedMessage = str_replace('\\', '^', $expectedMessage);
}
$expectedUser = 'Jimmy; echo deleting all your files now; # <jimmy@haxor.org; echo deleting all your files now; #>';
$expectedMessage = 'Message; echo deleting all your files now; #';

$lastCommit = $this->showLastCommit(base_path('content'));

Expand Down Expand Up @@ -400,6 +396,115 @@ 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()
{
// ShouldBeUnique acquires its cache lock in Bus\Dispatcher before the job
// reaches the queue driver, so Queue::fake() still enforces uniqueness here.
Queue::fake();

Git::dispatchCommit();
Git::dispatchCommit();
Git::dispatchCommit();

Queue::assertPushed(\Statamic\Git\CommitJob::class, 1);
}

#[Test]
public function it_attributes_coalesced_commits_to_the_configured_user()
{
Cache::put('statamic-git-pending-saves', [
['name' => 'Alice', 'email' => 'alice@example.com'],
['name' => 'Bob', 'email' => 'bob@example.com'],
]);

$user = User::make()->email('alice@example.com');

Git::shouldReceive('as')->with(null)->andReturnSelf()->once();
Git::shouldReceive('commit')->withArgs(function ($message) {
return str_contains($message, 'Entry saved')
&& str_contains($message, 'Co-Authored-By: Alice <alice@example.com>')
&& str_contains($message, 'Co-Authored-By: Bob <bob@example.com>');
})->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', [
['name' => 'Alice', 'email' => 'alice@example.com'],
]);

$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_adds_co_authored_by_trailers_for_coalesced_saves()
{
$this->files->put(base_path('content/collections/pages.yaml'), 'title: Pages Title Changed');

$message = "Content saved\n\nCo-Authored-By: Alice <alice@example.com>\nCo-Authored-By: Bob <bob@example.com>";

Git::commit($message);

$commit = $this->showLastCommit(base_path('content'));

$this->assertStringContainsString('Co-Authored-By: Alice <alice@example.com>', $commit);
$this->assertStringContainsString('Co-Authored-By: Bob <bob@example.com>', $commit);
}

#[Test]
public function it_deduplicates_co_authors_for_repeated_saves_by_the_same_user()
{
Cache::put('statamic-git-pending-saves', [
['name' => 'Alice', 'email' => 'alice@example.com'],
['name' => 'Alice', 'email' => 'alice@example.com'],
['name' => 'Bob', 'email' => 'bob@example.com'],
]);

$user = User::make()->email('alice@example.com');

Git::shouldReceive('as')->with(null)->andReturnSelf()->once();
Git::shouldReceive('commit')->withArgs(function ($message) {
return substr_count($message, 'Co-Authored-By:') === 2
&& str_contains($message, 'Co-Authored-By: Alice <alice@example.com>')
&& str_contains($message, 'Co-Authored-By: Bob <bob@example.com>');
})->once();

(new \Statamic\Git\CommitJob('Entry saved', $user))->handle();
}

#[Test]
public function it_wires_dispatch_to_co_authored_by_trailers_end_to_end()
{
Queue::fake();

$this->files->put(base_path('content/collections/pages.yaml'), 'title: Pages Title Changed');

$alice = User::make()->email('alice@example.com')->data(['name' => 'Alice'])->makeSuper();
$bob = User::make()->email('bob@example.com')->data(['name' => 'Bob'])->makeSuper();

$this->actingAs($alice);
Git::dispatchCommit();

$this->actingAs($bob);
Git::dispatchCommit(); // dropped by ShouldBeUnique, but Bob's save is still recorded in cache

(new \Statamic\Git\CommitJob(null, $alice))->handle();

$commit = $this->showLastCommit(base_path('content'));

$this->assertStringContainsString('Co-Authored-By: Alice <alice@example.com>', $commit);
$this->assertStringContainsString('Co-Authored-By: Bob <bob@example.com>', $commit);
}

#[Test]
public function it_doesnt_push_by_default()
{
Expand Down
Loading