I've been using Statamic as a CMS for this site for a few months now, and I can't do anything but sing its praises. The free Solo version of Statamic offers a generous set of features that meet 99% of my needs. The one thing that I've been pining after (that's only included as part of the Pro version) is the Git integration.
If you're not familiar with Statamic, it's a flat-file-first CMS, meaning that all of the site data such as settings, posts and pages are stored in a series of Markdown and YAML files as opposed to in a database. This allows you to easily sync the site data between development and production instances by committing these files to the site's repository.
The built-in Git integration within Statamic is only included as part of the Pro version, and whilst it's absolutely worth it for the rest of the features that are included, I wasn't prepared to pay $259 just for the Git integration. My use-case doesn't require all of the other features, and I was just craving a simple, completely automatic mechanism for my production instance of Statamic to commit and push any changes to my Git remote – so I just decided to build my own.
The approach I took was creating a command within my Laravel project that executes every 5 minutes. The command then stages, commits and pushes any changed files to the Git remote. This approach is assuming that the production instance is being deployed by pulling from the Git remote, or is simply already cloned from the same repository that the development instance is using.
Here's the full command:
// app/Console/Commands/UpdateGit.php
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
class UpdateGit extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'update-git';
/**
* The console command description.
*/
protected $description = 'Automatically updates adds, commits and pushes any changes';
/**
* Staged files
*/
public $stagedFiles = [];
/**
* Execute the console command.
*/
public function handle(): int
{
foreach (config('statamic.git.paths') as $path) {
$output = shell_exec("git status -s {$path}");
if ($output) {
shell_exec("git add {$path}");
$this->stagedFiles[] = $this->trimFileName($output);
}
}
$this->stagedFiles = array_flatten($this->stagedFiles);
if (!empty($this->stagedFiles)) {
$message = $this->commitMessage();
shell_exec("git commit -m '{$message}'");
shell_exec("git push");
}
return 0;
}
public function trimFileName(string $output): array
{
$files = [];
$lines = explode("\n", $output);
foreach ($lines as $line) {
$line = trim($line);
if (strlen($line)) {
$files[] = basename(substr($line, 2));
}
}
return $files;
}
public function commitMessage(): string
{
$stagedFileCount = count($this->stagedFiles);
return Str::of("[Auto-commit]: Updated {$this->stagedFiles[0]}")
->when(count($this->stagedFiles) > 1, function ($message) use ($stagedFileCount) {
return $message->append(' and ' . $stagedFileCount - 1 . ' other files');
})->toString();
}
}
This command is then executed every 5 minutes:
// app/Console/Kernel.php
protected function schedule(Schedule $schedule)
{
$schedule->command('update-git')->everyFiveMinutes();
}
Let's break down the logic that's going on in this command.
foreach (config('statamic.git.paths') as $path) {
$output = shell_exec("git status -s {$path}");
if ($output) {
shell_exec("git add {$path}");
$this->stagedFiles[] = $this->trimFileName($output);
}
}
Here we're looping over the paths defined in config('statamic.git.paths')
. These paths are already defined as part of every Statamic installation and define the directories that should be tracked for changes.
For each path, we're checking whether there are any untracked changes by running $output = shell_exec("git status -s {$path}");
. If the command doesn't return any output, then there haven't been any changes within this directory. If the command does return some output then the path is staged using shell_exec("git add {$path}");
and we trim the output from shell_exec("git status -s {$path}")
by making a call to the trimFileName()
method.
The output from running git status -s {$path}
looks something like this:
M content/collections/articles/2022-05-28.my-article-title.md
M resources/blueprints/collections/articles/articles.yaml
We want to parse this so that we're just left with an array of paths to track both the number of changed files, and which files have been changed so that we're able to create somewhat of a meaningful commit message. This is handled in the trimFileName()
method:
public function trimFileName(string $output): array
{
$files = [];
$lines = explode("\n", $output);
foreach ($lines as $line) {
$line = trim($line);
if (strlen($line)) {
$files[] = basename(substr($line, 2));
}
}
return $files;
}
Now that we have a 2-dimensional array of filenames stored in $this->stagedFiles
, we can flatten it into a 1-dimensional array:
$this->stagedFiles = array_flatten($this->stagedFiles);
From here, if we have any files staged for commit, the next step is to generate the commit message in the commitMessage()
method. For this. we use Laravel's fluent strings through the Illuminate\Support\Str
class to elegantly create our commit message based on the number of staged files.
public function commitMessage(): string
{
$stagedFileCount = count($this->stagedFiles);
return Str::of("[Auto-commit]: Updated {$this->stagedFiles[0]}")
->when(count($this->stagedFiles) > 1, function ($message) use ($stagedFileCount) {
return $message->append(' and ' . $stagedFileCount - 1 . ' other files');
})->toString();
}
If multiple files have been staged, the commit message will look a little something like this: [Auto-commit]: Updated my_staged_file.md and 3 other files
. If just one file has been staged, the commit message will look like this: [Auto-commit]: Updated my_staged_file.md
.
Then we commit the staged files and push them to the remote:
if (!empty($this->stagedFiles)) {
$message = $this->commitMessage();
shell_exec("git commit -m '{$message}'");
shell_exec("git push");
}
It's a barebones way of keeping files in sync between environments, but it can easily be adapted based on your individual needs.