(Grav GitSync) Automatic Commit from GitSync

This commit is contained in:
GitSync
2026-06-14 00:27:27 +00:00
parent a2920f812d
commit 3c1bfda80f
2933 changed files with 491625 additions and 0 deletions
@@ -0,0 +1,177 @@
<?php
namespace Grav\Plugin\GitSync;
use Grav\Common\Grav;
use Grav\Common\Plugin;
use Grav\Common\Utils;
use Grav\Plugin\Admin\AdminBaseController;
class AdminController extends AdminBaseController
{
protected $action;
protected $target;
protected $active;
protected $plugin;
protected $task_prefix = 'task';
/** @var GitSync */
public $git;
/**
* @param Plugin $plugin
*/
public function __construct(Plugin $plugin)
{
$this->grav = Grav::instance();
$this->active = false;
$uri = $this->grav['uri'];
$this->plugin = $plugin;
$post = !empty($_POST) ? $_POST : [];
$this->post = $this->getPost($post);
// Ensure the controller should be running
if (Utils::isAdminPlugin()) {
$routeDetails = $this->grav['admin']->getRouteDetails();
$target = array_pop($routeDetails);
$this->git = new GitSync();
// return null if this is not running
if ($target !== $plugin->name) {
return;
}
$this->action = !empty($this->post['action']) ? $this->post['action'] : $uri->param('action');
$this->target = $target;
$this->active = true;
$this->admin = Grav::instance()['admin'];
$task = !empty($post['task']) ? $post['task'] : $uri->param('task');
if ($task && ($this->target === $plugin->name || $uri->route() === '/lessons')) {
$this->task = $task;
$this->active = true;
}
}
}
public function taskTestConnection()
{
$post = $this->post;
$test = base64_decode($post['test']) ?: null;
$data = $test ? json_decode($test, false) : new \stdClass();
try {
$testResult = Helper::testRepository($data->user, $data->password, $data->repository, $data->branch);
if (!empty($testResult)) {
echo json_encode([
'status' => 'success',
'message' => 'The connection to the repository has been successful.'
]);
} else {
echo json_encode([
'status' => 'error',
'message' => 'Branch "' . $data->branch .'" not found in the repository.'
]);
}
} catch (\Exception $e) {
$invalid = str_replace($data->password, '{password}', $e->getMessage());
echo json_encode([
'status' => 'error',
'message' => $invalid
]);
}
exit;
}
public function taskSynchronize()
{
try {
$this->plugin->synchronize();
echo json_encode([
'status' => 'success',
'message' => 'GitSync has successfully synchronized with the repository.'
]);
} catch (\Exception $e) {
$invalid = str_replace($this->git->getConfig('password', null), '{password}', $e->getMessage());
echo json_encode([
'status' => 'error',
'message' => $invalid
]);
}
exit;
}
public function taskResetLocal()
{
try {
$this->plugin->reset();
echo json_encode([
'status' => 'success',
'message' => 'GitSync has successfully reset your local changes and synchronized with the repository.'
]);
} catch (\Exception $e) {
$invalid = str_replace($this->git->getConfig('password', null), '{password}', $e->getMessage());
echo json_encode([
'status' => 'error',
'message' => $invalid
]);
}
exit;
}
/**
* Performs a task or action on a post or target.
*
* @return bool
*/
public function execute()
{
$params = [];
// Handle Task & Action
if ($this->post && $this->task) {
// validate nonce
if (!$this->validateNonce()) {
return false;
}
$method = $this->task_prefix . ucfirst($this->task);
} elseif ($this->target) {
if (!$this->action) {
return false;
}
$method = strtolower($this->action) . ucfirst($this->target);
} else {
return false;
}
if (!method_exists($this, $method)) {
return false;
}
$success = $this->{$method}(...$params);
// Grab redirect parameter.
$redirect = $this->post['_redirect'] ?? null;
unset($this->post['_redirect']);
// Redirect if requested.
if ($redirect) {
$this->setRedirect($redirect);
}
return $success;
}
/**
* @return bool
*/
public function isActive()
{
return (bool) $this->active;
}
}
@@ -0,0 +1,391 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\GitSync\Api;
use Grav\Common\File\CompiledYamlFile;
use Grav\Common\Plugins;
use Grav\Plugin\Api\Controllers\AbstractApiController;
use Grav\Plugin\Api\Exceptions\ForbiddenException;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\Response\ApiResponse;
use Grav\Plugin\GitSync\GitSync;
use Grav\Plugin\GitSync\Helper;
use Grav\Plugin\GitSyncPlugin;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
/**
* Admin-Next API controller for git-sync.
*
* Endpoints back the blueprint-mode plugin settings page plus the wizard
* modal hosted by the auto-loaded floating widget script. Settings are
* persisted to config://plugins/git-sync.yaml — the same file admin-classic
* reads/writes — so the two admins stay interchangeable.
*/
class GitSyncApiController extends AbstractApiController
{
private function requireGitSyncPermission(ServerRequestInterface $request, string $level): void
{
$user = $this->getUser($request);
if ($this->isSuperAdmin($user)) {
return;
}
if (!$this->hasPermission($user, 'api.access')) {
throw new ForbiddenException('API access is not enabled for this user.');
}
$required = $level === 'write'
? ['api.git-sync', 'api.git-sync.write', 'api.git-sync.admin']
: ['api.git-sync', 'api.git-sync.read', 'api.git-sync.write', 'api.git-sync.admin'];
foreach ($required as $perm) {
if ($this->hasPermission($user, $perm)) {
return;
}
}
throw new ForbiddenException("Missing required Git Sync '{$level}' permission");
}
/**
* GET /git-sync/data — current settings for the plugin form.
*
* The raw encrypted password never leaves the server — we only signal
* whether one is stored, so the enc-password field can show its
* "securely stored" placeholder.
*/
public function data(ServerRequestInterface $request): ResponseInterface
{
$this->requireGitSyncPermission($request, 'read');
$cfg = (array) $this->config->get('plugins.git-sync', []);
$sync = (array) ($cfg['sync'] ?? []);
$remote = (array) ($cfg['remote'] ?? []);
$git = (array) ($cfg['git'] ?? []);
return ApiResponse::create([
'enabled' => (bool) ($cfg['enabled'] ?? false),
'folders' => array_values((array) ($cfg['folders'] ?? ['pages'])),
'local_repository' => (string) ($cfg['local_repository'] ?? ''),
'repository' => (string) ($cfg['repository'] ?? ''),
'no_user' => (bool) ($cfg['no_user'] ?? false),
'user' => (string) ($cfg['user'] ?? ''),
// Form binds an empty string by default; server keeps the existing
// password unless the user types a new one. Storage state is
// exposed on the resolved blueprint (see onApiBlueprintResolved)
// so the enc-password component can render the right placeholder.
'password' => '',
'webhook' => (string) ($cfg['webhook'] ?? ''),
'webhook_enabled' => (bool) ($cfg['webhook_enabled'] ?? false),
'webhook_secret' => (string) ($cfg['webhook_secret'] ?? ''),
'branch' => (string) ($cfg['branch'] ?? 'master'),
'logging' => (bool) ($cfg['logging'] ?? false),
'sync' => [
'direction' => (string) ($sync['direction'] ?? 'both'),
'on_save' => (bool) ($sync['on_save'] ?? true),
'on_delete' => (bool) ($sync['on_delete'] ?? true),
'on_media' => (bool) ($sync['on_media'] ?? true),
'cron_enable' => (bool) ($sync['cron_enable'] ?? false),
'cron_at' => (string) ($sync['cron_at'] ?? '0 12,23 * * *'),
],
'remote' => [
'name' => (string) ($remote['name'] ?? 'origin'),
'branch' => (string) ($remote['branch'] ?? 'master'),
],
'git' => [
'author' => (string) ($git['author'] ?? 'gituser'),
'message' => (string) ($git['message'] ?? '(Grav GitSync) Automatic Commit'),
'name' => (string) ($git['name'] ?? 'GitSync'),
'email' => (string) ($git['email'] ?? 'git-sync@trilby.media'),
'bin' => (string) ($git['bin'] ?? 'git'),
'ignore' => (string) ($git['ignore'] ?? ''),
'private_key' => (string) ($git['private_key'] ?? ''),
],
]);
}
/**
* PATCH /git-sync/data — persist plugin settings.
*
* Mirrors the admin-classic onAdminSave password handling: if the form
* sends an empty password we keep whatever is currently stored (and
* encrypt it if it was somehow saved in plaintext); a non-empty value
* gets encrypted before write.
*/
public function save(ServerRequestInterface $request): ResponseInterface
{
$this->requireGitSyncPermission($request, 'write');
$body = $this->getRequestBody($request);
$existing = (array) $this->config->get('plugins.git-sync', []);
$merged = $existing;
// Top-level scalars / lists
foreach (['enabled', 'folders', 'local_repository', 'repository', 'no_user',
'user', 'webhook', 'webhook_enabled', 'webhook_secret', 'branch', 'logging'] as $key) {
if (array_key_exists($key, $body)) {
$merged[$key] = $body[$key];
}
}
// Password — empty means "keep existing", non-empty means "encrypt & replace"
$newPassword = $body['password'] ?? null;
if ($newPassword === null || $newPassword === '') {
$current = (string) ($existing['password'] ?? '');
if ($current !== '' && !str_starts_with($current, 'gitsync-')) {
$merged['password'] = Helper::encrypt($current);
} else {
$merged['password'] = $current;
}
} else {
$merged['password'] = Helper::encrypt((string) $newPassword);
}
// Nested: sync / remote / git
foreach (['sync', 'remote', 'git'] as $section) {
if (isset($body[$section]) && is_array($body[$section])) {
$merged[$section] = array_merge((array) ($merged[$section] ?? []), $body[$section]);
}
}
// Auto-generate webhook / webhook_secret if blank, matching admin-classic's data-default@
if (empty($merged['webhook'])) {
$merged['webhook'] = GitSyncPlugin::generateRandomWebhook();
}
if (empty($merged['webhook_secret'])) {
$merged['webhook_secret'] = GitSyncPlugin::generateWebhookSecret();
}
$this->writePluginConfig($merged);
// Mirror admin-classic onAdminAfterSave: initialize repo / set remote
// when the plugin page form is saved with a configured repository.
if (Helper::isGitInstalled() && Helper::isGitSyncConfigured()) {
try {
$git = new GitSync();
$git->setConfig($merged);
$git->initializeRepository();
$git->setUser();
$git->addRemote();
} catch (\Throwable $e) {
// Don't fail the save — surface as a warning in the response.
return ApiResponse::create([
'message' => 'Settings saved, but repository setup ran into an issue: '
. Helper::preventReadablePassword($e->getMessage(), $merged['password'] ?? ''),
]);
}
}
return ApiResponse::create([
'message' => 'Git Sync settings saved.',
]);
}
/**
* POST /git-sync/sync — synchronize with the remote repository.
*/
public function sync(ServerRequestInterface $request): ResponseInterface
{
$this->requireGitSyncPermission($request, 'write');
if (!Helper::isGitInstalled()) {
throw new ValidationException('Git is not installed or not on the configured PATH.');
}
if (!Helper::isGitSyncReady()) {
throw new ValidationException('Git Sync is not configured yet — run the Wizard first.');
}
@set_time_limit(0);
// Release the PHP session lock so the rest of admin-next stays
// responsive while the network-bound git pull/push finishes.
// Without this, every concurrent request from the same browser
// blocks behind this one and the UI feels frozen.
@session_write_close();
try {
$plugin = $this->getGitSyncPlugin();
$plugin->synchronize();
} catch (\Throwable $e) {
$password = (string) ($this->config->get('plugins.git-sync.password') ?? '');
throw new ValidationException(
Helper::preventReadablePassword($e->getMessage(), $password)
);
}
return ApiResponse::create([
'message' => 'Git Sync has successfully synchronized with the repository.',
]);
}
/**
* POST /git-sync/reset — discard local changes (git reset --hard HEAD).
*/
public function reset(ServerRequestInterface $request): ResponseInterface
{
$this->requireGitSyncPermission($request, 'write');
if (!Helper::isGitInstalled()) {
throw new ValidationException('Git is not installed or not on the configured PATH.');
}
if (!Helper::isGitSyncReady()) {
throw new ValidationException('Git Sync is not configured yet — run the Wizard first.');
}
@set_time_limit(0);
@session_write_close();
try {
$plugin = $this->getGitSyncPlugin();
$plugin->reset();
} catch (\Throwable $e) {
$password = (string) ($this->config->get('plugins.git-sync.password') ?? '');
throw new ValidationException(
Helper::preventReadablePassword($e->getMessage(), $password)
);
}
return ApiResponse::create([
'message' => 'Git Sync has reset your local copy and re-synchronized with the repository.',
]);
}
/**
* POST /git-sync/test-connection — wizard "Verify Authentication, Connection and Branch".
*
* Body: { user, password, repository, branch, no_user }
*
* Mirrors AdminController::taskTestConnection. The credentials are NOT
* persisted — they exist only for the duration of this ls-remote probe.
*/
public function testConnection(ServerRequestInterface $request): ResponseInterface
{
$this->requireGitSyncPermission($request, 'write');
if (!Helper::isGitInstalled()) {
throw new ValidationException('Git is not installed or not on the configured PATH.');
}
$body = $this->getRequestBody($request);
$user = (string) ($body['user'] ?? '');
$password = (string) ($body['password'] ?? '');
$repository = (string) ($body['repository'] ?? '');
$branch = (string) ($body['branch'] ?? '');
$noUser = (bool) ($body['no_user'] ?? false);
if ($repository === '') {
throw new ValidationException('Repository URL is required.');
}
if ($branch === '') {
throw new ValidationException('Branch is required.');
}
if ($noUser) {
$user = '';
}
try {
$result = Helper::testRepository($user, $password, $repository, $branch);
} catch (\Throwable $e) {
$message = str_replace($password, '{password}', $e->getMessage());
return ApiResponse::create([
'status' => 'error',
'message' => $message,
]);
}
if (empty($result)) {
return ApiResponse::create([
'status' => 'error',
'message' => "Branch \"{$branch}\" not found in the repository.",
]);
}
return ApiResponse::create([
'status' => 'success',
'message' => 'Connection to the repository was successful.',
]);
}
/**
* GET /git-sync/wizard/state — pre-flight + current settings for the wizard.
*
* The wizard reuses the saved repo / branch / webhook to pre-fill its
* inputs the second time around (admin-classic does the same via Twig).
*/
public function wizardState(ServerRequestInterface $request): ResponseInterface
{
$this->requireGitSyncPermission($request, 'read');
$cfg = (array) $this->config->get('plugins.git-sync', []);
$password = (string) ($cfg['password'] ?? '');
// Compute the public site URL the way Twig admin-classic does it
// (`uri.base ~ uri.rootUrl`). The browser-side `window.location.origin`
// alone misses Grav installs that live in a sub-folder, leaving the
// wizard's webhook URL preview wrong. Trailing slash trimmed so the
// client can append the webhook path cleanly.
$uri = $this->grav['uri'];
$frontendUrl = rtrim($uri->base() . $uri->rootUrl(), '/');
return ApiResponse::create([
'git_installed' => (bool) Helper::isGitInstalled(),
'git_initialized' => (bool) Helper::isGitInitialized(),
'configured' => (bool) Helper::isGitSyncConfigured(),
'frontend_url' => $frontendUrl,
'settings' => [
'repository' => (string) ($cfg['repository'] ?? ''),
'no_user' => (bool) ($cfg['no_user'] ?? false),
'user' => (string) ($cfg['user'] ?? ''),
'password_stored' => $password !== '',
'branch' => (string) ($cfg['branch'] ?? ''),
'webhook' => (string) ($cfg['webhook'] ?? GitSyncPlugin::generateRandomWebhook()),
'webhook_enabled' => (bool) ($cfg['webhook_enabled'] ?? false),
'webhook_secret' => (string) ($cfg['webhook_secret'] ?? GitSyncPlugin::generateWebhookSecret()),
'folders' => array_values((array) ($cfg['folders'] ?? ['pages'])),
],
]);
}
/**
* Resolve the live GitSyncPlugin instance.
*
* `$grav['plugins']->get('git-sync')` returns a Data wrapper around the
* blueprint, not the plugin instance — this fetches the actual plugin
* via Plugins::getPlugin(), which is what we need to call synchronize()
* and reset().
*/
private function getGitSyncPlugin(): GitSyncPlugin
{
$plugin = Plugins::getPlugin('git-sync');
if (!$plugin instanceof GitSyncPlugin) {
throw new ValidationException('Git Sync plugin is not loaded.');
}
return $plugin;
}
private function writePluginConfig(array $data): void
{
$locator = $this->grav['locator'];
$pluginsDir = $locator->findResource('config://plugins', true, true);
if (!$pluginsDir) {
throw new ValidationException('Could not resolve config://plugins directory.');
}
if (!is_dir($pluginsDir)) {
@mkdir($pluginsDir, 0775, true);
}
$file = CompiledYamlFile::instance($pluginsDir . '/git-sync.yaml');
$file->save($data);
$file->free();
$this->config->set('plugins.git-sync', $data);
$cache = $this->grav['cache'] ?? null;
if ($cache && method_exists($cache, 'clearCache')) {
$cache->clearCache('standard');
}
}
}
+551
View File
@@ -0,0 +1,551 @@
<?php
namespace Grav\Plugin\GitSync;
use Grav\Common\Grav;
use Grav\Common\Plugin;
use Grav\Common\Utils;
use http\Exception\RuntimeException;
use RocketTheme\Toolbox\File\File;
use SebastianBergmann\Git\Git;
class GitSync extends Git
{
/** @var static */
static public $instance;
/** @var Grav */
protected $grav;
/** @var Plugin */
protected $plugin;
/** @var array */
protected $config;
/** @var string */
protected $repositoryPath;
/** @var string|null */
private $user;
/** @var string|null */
private $password;
public function __construct()
{
$this->grav = Grav::instance();
$this->config = $this->grav['config']->get('plugins.git-sync') ?? [];
$this->repositoryPath = isset($this->config['local_repository']) && $this->config['local_repository'] ? $this->config['local_repository'] : USER_DIR;
parent::__construct($this->repositoryPath);
static::$instance = $this;
$this->user = isset($this->config['no_user']) && $this->config['no_user'] ? '' : ($this->config['user'] ?? null);
$this->password = $this->config['password'] ?? null;
unset($this->config['user'], $this->config['password']);
}
/**
* @return static
*/
public static function instance()
{
if (null === static::$instance) {
static::$instance = new static;
}
return static::$instance;
}
/**
* @return string|null
*/
public function getUser()
{
return $this->user;
}
/**
* @return string|null
*/
public function getPassword()
{
return $this->password;
}
/**
* @param array $config
*/
public function setConfig($config)
{
$this->config = $config ?? [];
$this->user = $this->config['user'] ?? null;
$this->password = $this->config['password'] ?? null;
}
/**
* @return array
*/
public function getRuntimeInformation()
{
$result = [
'repositoryPath' => $this->repositoryPath,
'username' => $this->user,
'password' => $this->password
];
foreach ($this->config as $key => $item) {
if (is_array($item)) {
$count = count($item);
$arr = $item;
if ($count === 0) {// empty array, could still be associative
$arr = '[]';
} else if (isset($item[0])) {// fast check for plain array with numeric keys
$arr = '[\'' . implode('\', \'', $item) . '\']';
}
$result[$key] = $arr;
} else {
$result[$key] = $item;
}
}
return $result;
}
/**
* @param string $url
* @return string[]
*/
public function testRepository($url, $branch)
{
if (!preg_match(Helper::GIT_REGEX, $url)) {
throw new \RuntimeException("Git Repository value does not match the supported format.");
}
$branch = $branch ? '"' . $branch . '"' : '';
return $this->execute("ls-remote \"{$url}\" {$branch}");
}
/**
* @return bool
*/
public function initializeRepository()
{
if (!Helper::isGitInitialized()) {
$branch = $this->getRemote('branch', null);
$local_branch = $this->getConfig('branch', $branch);
$this->execute('init');
$this->execute('checkout -b ' . $local_branch, true);
}
$this->enableSparseCheckout();
return true;
}
/**
* @param string|null $name
* @param string|null $email
* @return bool
*/
public function setUser($name = null, $email = null)
{
$gitConfig = $this->getConfig('git', []) ?? [];
// Fall back to defaults when the config value is missing OR an empty
// string — `??` alone leaves a blank name/email in place, which makes
// git reject the commit with "fatal: empty ident name ... not allowed".
$name = $name ?: (($gitConfig['name'] ?? '') ?: 'GitSync');
$email = $email ?: (($gitConfig['email'] ?? '') ?: 'git-sync@trilby.media');
$privateKey = $this->getGitConfig('private_key', null);
$this->execute("config user.name \"{$name}\"");
$this->execute("config user.email \"{$email}\"");
if ($privateKey) {
$this->execute('config core.sshCommand "ssh -i ' . $privateKey . ' -F /dev/null"');
} else {
$this->execute('config --unset core.sshCommand');
}
return true;
}
/**
* @param string|null $name
* @return bool
*/
public function hasRemote($name = null)
{
$name = $this->getRemote('name', $name);
try {
/** @var string $version */
$version = Helper::isGitInstalled(true);
// remote get-url 'name' supported from 2.7.0 and above
if (version_compare($version, '2.7.0', '>=')) {
$command = "remote get-url \"{$name}\"";
} else {
$command = "config --get remote.{$name}.url";
}
$this->execute($command);
} catch (\Exception $e) {
return false;
}
return true;
}
public function enableSparseCheckout()
{
$folders = $this->config['folders'] ?? ['pages'];
$this->execute('config core.sparsecheckout true');
$sparse = [];
foreach ($folders as $folder) {
$sparse[] = $folder . '/';
$sparse[] = $folder . '/*';
}
$file = File::instance(rtrim($this->repositoryPath, '/') . '/.git/info/sparse-checkout');
$file->save(implode("\r\n", $sparse));
$file->free();
$ignore = ['/*'];
foreach ($folders as $folder) {
$folder = rtrim($folder,'/');
$nested = substr_count($folder, '/');
if ($nested) {
$subfolders = explode('/', $folder);
$nested_tracking = '';
foreach ($subfolders as $index => $subfolder) {
$last = $index === (count($subfolders) - 1);
$nested_tracking .= $subfolder . '/';
if (!in_array('!/' . $nested_tracking, $ignore, true)) {
$ignore[] = rtrim($nested_tracking . (!$last ? '*' : ''), '/');
$ignore[] = rtrim('!/' . $nested_tracking, '/');
}
}
} else {
$ignore[] = '!/' . $folder;
}
}
$ignoreEntries = explode("\n", $this->getGitConfig('ignore', ''));
$ignore = array_merge($ignore, $ignoreEntries);
$file = File::instance(rtrim($this->repositoryPath, '/') . '/.gitignore');
$file->save(implode("\r\n", $ignore));
$file->free();
}
/**
* @param string|null $alias
* @param string|null $url
* @param bool $authenticated
* @return string[]
*/
public function addRemote($alias = null, $url = null, $authenticated = false)
{
$alias = $this->getRemote('name', $alias);
$url = $this->getConfig('repository', $url);
if ($authenticated) {
$user = $this->user ?? '';
$password = $this->password ? Helper::decrypt($this->password) : '';
$url = Helper::prepareRepository($user, $password, $url);
}
$command = $this->hasRemote($alias) ? 'set-url' : 'add';
return $this->execute("remote {$command} {$alias} \"{$url}\"");
}
/**
* @return string[]
*/
public function add()
{
/** @var string $version */
$version = Helper::isGitInstalled(true);
$add = 'add';
// With the introduction of customizable paths,
// it appears that the add command should always
// add everything that is not committed to ensure
// there are no orphan changes left behind
/*
$folders = $this->config['folders'] ?? ['pages'];
$paths = [];
foreach ($folders as $folder) {
$paths[] = $folder;
}
*/
$paths = ['.'];
if (version_compare($version, '2.0', '<')) {
$add .= ' --all';
}
return $this->execute($add . ' ' . implode(' ', $paths));
}
/**
* @param string $message
* @return string[]
*/
public function commit($message = '(Grav GitSync) Automatic Commit')
{
$authorType = $this->getGitConfig('author', 'gituser');
if (defined('GRAV_CLI') && in_array($authorType, ['gravuser', 'gravfull'])) {
$authorType = 'gituser';
}
// get message from config, it any, or stick to the default one
$config = $this->getConfig('git', null);
$message = $config['message'] ?? $message;
// get Page Title and Route from Post
$uri = $this->grav['uri'];
$page_title = $uri->post('data.header.title');
$page_route = $uri->post('data.route');
$pageTitle = $page_title ?: 'NO TITLE FOUND';
$pageRoute = $page_route ?: 'NO ROUTE FOUND';
// include page title and route in the message, if placeholders exist
$message = str_replace('{{pageTitle}}', $pageTitle, $message);
/** @var string $message */
$message = str_replace('{{pageRoute}}', $pageRoute, $message);
$gitConfig = $this->getConfig('git', []) ?? [];
switch ($authorType) {
case 'gitsync':
$user = $gitConfig['name'] ?? 'GitSync';
$email = $gitConfig['email'] ?? 'git-sync@trilby.media';
break;
case 'gravuser':
$user = $this->grav['session']->user->username ?? 'GitSync';
$email = $this->grav['session']->user->email ?? 'git-sync@trilby.media';
break;
case 'gravfull':
$user = $this->grav['session']->user->fullname ?? 'GitSync';
$email = $this->grav['session']->user->email ?? 'git-sync@trilby.media';
break;
case 'gituser':
default:
$user = $this->user ?? 'GitSync';
$email = $gitConfig['email'] ?? 'git-sync@trilby.media';
break;
}
// Guard against empty values from any source (e.g. a Grav user with no
// full name set, or a blank committer field) — an empty author name
// triggers git's "fatal: empty ident name ... not allowed".
$user = $user ?: 'GitSync';
$email = $email ?: 'git-sync@trilby.media';
$author = $user . ' <' . $email . '>';
$author = '--author="' . $author . '"';
$message .= ' from ' . $user;
$this->add();
return $this->execute('commit ' . $author . ' -m ' . escapeshellarg($message));
}
/**
* @param string|null $name
* @param string|null $branch
* @return string[]
*/
public function fetch($name = null, $branch = null)
{
$name = $this->getRemote('name', $name);
$branch = $this->getRemote('branch', $branch);
return $this->execute("fetch {$name} {$branch}");
}
/**
* @param string|null $name
* @param string|null $branch
* @return string[]
*/
public function pull($name = null, $branch = null)
{
$name = $this->getRemote('name', $name);
$branch = $this->getRemote('branch', $branch);
/** @var string $version */
$version = Helper::isGitInstalled(true);
$unrelated_histories = '--allow-unrelated-histories';
// --allow-unrelated-histories starts at 2.9.0
if (version_compare($version, '2.9.0', '<')) {
$unrelated_histories = '';
}
return $this->execute("pull {$unrelated_histories} --ff -X theirs {$name} {$branch}");
}
/**
* @param string|null $name
* @param string|null $branch
* @return string[]
*/
public function push($name = null, $branch = null)
{
$name = $this->getRemote('name', $name);
$branch = $this->getRemote('branch', $branch);
$local_branch = $this->getConfig('branch', null);
return $this->execute("push {$name} {$local_branch}:{$branch}");
}
/**
* @param string|null $name
* @param string|null $branch
* @return bool
*/
public function sync($name = null, $branch = null)
{
$name = $this->getRemote('name', $name);
$branch = $this->getRemote('branch', $branch);
$this->addRemote(null, null, true);
$this->fetch($name, $branch);
$this->pull($name, $branch);
if ($this->grav['config']->get('plugins.git-sync.sync.direction', 'both') == 'both') {
$this->push($name, $branch);
}
$this->addRemote();
return true;
}
/**
* @return string[]
*/
public function reset()
{
return $this->execute('reset --hard HEAD');
}
/**
* @return bool
*/
public function isWorkingCopyClean()
{
$message = 'nothing to commit';
$output = $this->execute('status');
return strpos($output[count($output) - 1], $message) === 0;
}
/**
* @return bool
*/
public function hasChangesToCommit()
{
$folders = $this->config['folders'] ?? ['pages'];
$paths = [];
foreach ($folders as $folder) {
$folder = explode('/', $folder);
$paths[] = array_shift($folder);
}
$message = 'nothing to commit';
$output = $this->execute('status ' . implode(' ', $paths));
return strpos($output[count($output) - 1], $message) !== 0;
}
/**
* @param string $command
* @param bool $quiet
* @return string[]
*/
public function execute($command, $quiet = false)
{
try {
$bin = Helper::getGitBinary($this->getGitConfig('bin', 'git'));
/** @var string $version */
$version = Helper::isGitInstalled(true);
// -C <path> supported from 1.8.5 and above
if (version_compare($version, '1.8.5', '>=')) {
$command = $bin . ' -C ' . escapeshellarg($this->repositoryPath) . ' ' . $command;
} else {
$command = 'cd ' . $this->repositoryPath . ' && ' . $bin . ' ' . $command;
}
$command .= ' 2>&1';
if (DIRECTORY_SEPARATOR === '/') {
$command = 'LC_ALL=C ' . $command;
}
if ($this->getConfig('logging', false)) {
$log_command = Helper::preventReadablePassword($command, $this->password ?? '');
$this->grav['log']->notice('gitsync[command]: ' . $log_command);
exec($command, $output, $returnValue);
$log_output = Helper::preventReadablePassword(implode("\n", $output), $this->password ?? '');
$this->grav['log']->notice('gitsync[output]: ' . $log_output);
} else {
exec($command, $output, $returnValue);
}
if ($returnValue !== 0 && $returnValue !== 5 && !$quiet) {
throw new \RuntimeException(implode("\r\n", $output));
}
return $output;
} catch (\RuntimeException $e) {
$message = $e->getMessage();
$message = Helper::preventReadablePassword($message, $this->password ?? '');
// handle scary messages
if (Utils::contains($message, 'remote: error: cannot lock ref')) {
$message = 'GitSync: An error occurred while trying to synchronize. This could mean GitSync is already running. Please try again.';
}
throw new \RuntimeException($message);
}
return 0;
}
/**
* @param string $type
* @param mixed $value
* @return mixed
*/
public function getGitConfig($type, $value)
{
return $this->config['git'][$type] ?? $value;
}
/**
* @param string $type
* @param mixed $value
* @return mixed
*/
public function getRemote($type, $value)
{
return $value ?: ($this->config['remote'][$type] ?? $value);
}
/**
* @param string $type
* @param mixed $value
* @return mixed
*/
public function getConfig($type, $value)
{
return $value ?: ($this->config[$type] ?? $value);
}
}
+183
View File
@@ -0,0 +1,183 @@
<?php
namespace Grav\Plugin\GitSync;
use Defuse\Crypto\Crypto;
use Grav\Common\Config\Config;
use Grav\Common\Grav;
use Grav\Common\Utils;
use SebastianBergmann\Git\RuntimeException;
class Helper
{
/** @var string */
private static $hash = '594ef69d-6c29-45f7-893a-f1b4342687d3';
/** @var string */
const GIT_REGEX = '/(?:git|ssh|https?|git@[-\w.]+):(\/\/)?(.*?)(\.git)(\/?|\#[-\d\w._]+?)$/';
/**
* Checks if git-sync is properly configured with a repository URL
*
* @return bool
*/
public static function isGitSyncConfigured()
{
$config = Grav::instance()['config']->get('plugins.git-sync');
$repository = $config['repository'] ?? null;
return !empty($repository);
}
/**
* Checks if git-sync is ready to use (installed, configured, and initialized)
*
* @return bool
*/
public static function isGitSyncReady()
{
return static::isGitInstalled() && static::isGitSyncConfigured() && static::isGitInitialized();
}
/**
* Checks if the user/ folder is already initialized
*
* @return bool
*/
public static function isGitInitialized()
{
/** @var Config $grav */
$config = Grav::instance()['config']->get('plugins.git-sync');
$repositoryPath = isset($config['local_repository']) && $config['local_repository'] ? $config['local_repository'] : USER_DIR;
return file_exists(rtrim($repositoryPath, '/') . '/.git');
}
/**
* @param bool $version
* @return bool|string
*/
public static function isGitInstalled($version = false)
{
$bin = Helper::getGitBinary();
exec($bin . ' --version', $output, $returnValue);
$installed = $returnValue === 0;
if ($version && $output) {
$output = explode(' ', array_shift($output));
$versions = array_filter($output, static function($item) {
return version_compare($item, '0.0.1', '>=');
});
$installed = array_shift($versions);
}
return $installed;
}
/**
* @param bool $override
* @return string
*/
public static function getGitBinary($override = false)
{
/** @var Config $grav */
$config = Grav::instance()['config'];
return $override ?: $config->get('plugins.git-sync.git.bin', 'git');
}
/**
* @param string $user
* @param string $password
* @param string $repository
* @return string
*/
public static function prepareRepository($user, $password, $repository)
{
$user = $user ? urlencode($user) . ':' : '';
$password = urlencode($password);
if (Utils::startsWith($repository, 'ssh://')) {
return $repository;
}
return str_replace('://', "://{$user}{$password}@", $repository);
}
/**
* @param string $user
* @param string $password
* @param string $repository
* @return string[]
*/
public static function testRepository($user, $password, $repository, $branch)
{
$git = new GitSync();
$repository = self::prepareRepository($user, $password, $repository);
try {
return $git->testRepository($repository, $branch);
} catch (RuntimeException $e) {
return [$e->getMessage()];
}
}
/**
* @param string $password
* @return string
* @throws \Defuse\Crypto\Exception\EnvironmentIsBrokenException
*/
public static function encrypt($password)
{
return 'gitsync-' . Crypto::encryptWithPassword($password, self::$hash);
}
/**
* @param string $enc_password
* @return string
*/
public static function decrypt($enc_password)
{
if (strpos($enc_password, 'gitsync-') === 0) {
$enc_password = substr($enc_password, 8);
return Crypto::decryptWithPassword($enc_password, self::$hash);
}
return $enc_password;
}
/**
* @return bool
*/
public static function synchronize()
{
if (!self::isGitInstalled() || !self::isGitInitialized()) {
return true;
}
$git = new GitSync();
if ($git->hasChangesToCommit()) {
$git->commit();
}
// synchronize with remote
$git->sync();
return true;
}
/**
* @param string $str
* @param string $password
* @return string
*/
public static function preventReadablePassword($str, $password)
{
$encoded = urlencode(self::decrypt($password));
return str_replace($encoded, '{password}', $str);
}
}