stevegrunwell/smee

View on GitHub
src/Project.php

Summary

Maintainability
A
35 mins
Test Coverage
<?php

namespace Smee;

use Composer\Script\Event;
use SebastianBergmann\Diff\Differ;
use Smee\Exceptions\HookExistsException;
use Smee\Exceptions\NoGitDirectoryException;
use Smee\Exceptions\NoHooksDirectoryException;

class Project
{
    /**
     * File mode for copied Git hooks.
     *
     * To be compatible with chmod(), this should be in octal form.
     */
    const HOOK_FILEMODE = 0755;

    /**
     * Contains the full system path to the base of the project.
     *
     * @var string $baseDir
     */
    protected $baseDir;

    /**
     * Contains an array of all Git hooks that have been copied.
     *
     * @var array $copied
     */
    protected $copied = [];

    /**
     * Contains the path relative to the hooks directory, relative to the project root.
     *
     * @var string $hooksDir
     */
    protected $hooksDir;

    /**
     * Contains an array of all Git hooks that have been skipped.
     *
     * @var array $skipped
     */
    protected $skipped = [];

    /**
     * Instantiate a new project with Smee.
     *
     * @param string $hooksDir Optional. The hooks directory, relative to the project root. Default
     *                         is the .githooks directory.
     * @param string $baseDir  The full system path to the root of the project directory.
     */
    public function __construct($baseDir, $hooksDir = '.githooks')
    {
        $this->baseDir = $this->stripTrailingSlashes($baseDir);
        $this->hooksDir = $this->baseDir . '/' . $this->stripTrailingSlashes($hooksDir);
    }

    /**
     * Copy the hooks from the hooks directory into the local git repository.
     *
     * @throws NoGitDirectoryException   When there is no .git directory.
     * @throws NoHooksDirectoryException When the git hooks directory is missing.
     *
     * @return array An array containing the filenames of all copied hooks.
     */
    public function copyHooks()
    {
        // Throw Exceptions if either the .git or hooks directories are missing.
        if (! is_dir($this->baseDir . '/.git')) {
            throw new NoGitDirectoryException(sprintf('No .git directory was found within %s.', $this->baseDir));
        } elseif (! is_dir($this->hooksDir) || ! is_readable($this->hooksDir)) {
            throw new NoHooksDirectoryException(
                sprintf('The git hooks directory at %s is inaccessible.', $this->hooksDir)
            );
        }

        // Read the contents of $this->hooksDir and copy them.
        $contents = scandir($this->hooksDir);

        array_map([$this, 'copyHook'], $contents);

        return $this->copied;
    }

    /**
     * Copy a single hook from the local directory to .git/hooks.
     *
     * @throws HookExistsException If the target hook already exists.
     *
     * @param string $hook  The name of the hook to copy from $this->hooksDir.
     * @param bool   $force Force overwrite of existing hooks. Default is false.
     *
     * @return bool True if the hook was copied, false if it was ineligible to be copied.
     */
    public function copyHook($hook, $force = false)
    {
        $hook = basename($hook);
        $path = $this->hooksDir . '/' . $hook;
        $dest = $this->baseDir . '/.git/hooks/' . $hook;

        if (in_array($hook, $this->skipped, true) || is_dir($path)) {
            return false;
        }

        if (file_exists($dest) && ! $force) {
            // The file exists, but it's the same as what we're about to copy.
            if (md5_file($dest) === md5_file($path)) {
                $this->skipHook($hook);
                return false;
            }

            $exception = new HookExistsException(sprintf('A %s hook already exists for this repository!', $hook));
            $exception->hook = $hook;

            throw $exception;
        }

        // Temporarily hijack the error handler.
        // @codingStandardsIgnoreLine
        set_error_handler(function () {});
        $copied = copy($path, $dest);
        restore_error_handler();

        if (! $copied) {
            return false;
        }

        // Set file permissions and log that the hook has been copied.
        chmod($dest, self::HOOK_FILEMODE);
        $this->copied[] = $hook;

        return true;
    }

    /**
     * Generate a diff based on the hook name and the project configuration.
     *
     * @param string $hook The name of the hook file, which should exist in both .git/hooks and
     *                     $this->hooksDir.
     *
     * @return string A diff of the two files.
     */
    public function diffHook($hook)
    {
        $file   = $this->hooksDir . '/' . $hook;
        $target = $this->baseDir . '/.git/hooks/' . $hook;

        return $this->diffHooks($file, $target);
    }

    /**
     * Generate a diff between two hook files.
     *
     * @param string $file   The filepath to the new hook file.
     * @param string $target The filepath to the existing hook file.
     *
     * @return string A human-readable diff.
     */
    public function diffHooks($file, $target)
    {
        $differ = new Differ;

        return $differ->diff(file_get_contents($target), file_get_contents($file));
    }

    /**
     * Retrieve an array of copied hooks.
     *
     * @return array An array of copied hook names.
     */
    public function getCopiedHooks()
    {
        return (array) $this->copied;
    }

    /**
     * Retrieve an array of skipped hooks.
     *
     * @return array An array of skipped hook names.
     */
    public function getSkippedHooks()
    {
        return (array) $this->skipped;
    }

    /**
     * Mark a hook to be skipped.
     *
     * @param string $hook The hook name to skip.
     */
    public function skipHook($hook)
    {
        $this->skipped[] = $hook;
    }

    /**
     * Remove trailing slashes from a directory name.
     *
     * @param string $path The path or URL to strip trailing slashes from.
     *
     * @return string The $path, devoid of trailing slashes.
     */
    protected function stripTrailingSlashes($path)
    {
        return '/' === substr($path, -1, 1) ? substr($path, 0, -1) : $path;
    }
}