<?php

/*
 * This file is part of the LightSAML-Core package.
 *
 * (c) Milos Tomic <tmilos@lightsaml.com>
 *
 * This source file is subject to the MIT license that is bundled
 * with this source code in the file LICENSE.
 */

namespace LightSaml\Model;

use LightSaml\Error\LightSamlXmlException;
use LightSaml\Model\Context\DeserializationContext;
use LightSaml\Model\Context\SerializationContext;

abstract class AbstractSamlModel implements SamlElementInterface
{
    /**
     * @param string               $name
     * @param null|string          $namespace
     * @param \DOMNode             $parent
     * @param SerializationContext $context
     *
     * @return \DOMElement
     */
    protected function createElement($name, $namespace, \DOMNode $parent, SerializationContext $context)
    {
        if ($namespace) {
            $result = $context->getDocument()->createElementNS($namespace, $name);
        } else {
            $result = $context->getDocument()->createElement($name);
        }
        $parent->appendChild($result);

        return $result;
    }

    /**
     * @param string               $name
     * @param \DOMNode             $parent
     * @param SerializationContext $context
     * @param string|null          $namespace
     *
     * @throws \LogicException
     */
    private function oneElementToXml($name, \DOMNode $parent, SerializationContext $context, $namespace = null)
    {
        $value = $this->getPropertyValue($name);
        if (null == $value) {
            return;
        }
        if ($value instanceof SamlElementInterface) {
            $value->serialize($parent, $context);
        } elseif (is_string($value)) {
            if ($namespace) {
                $node = $context->getDocument()->createElementNS($namespace, $name, $value);
            } else {
                $node = $context->getDocument()->createElement($name, $value);
            }
            $parent->appendChild($node);
        } else {
            throw new \LogicException(sprintf("Element '%s' must implement SamlElementInterface or be a string", $name));
        }
    }

    /**
     * @param array|string[]       $names
     * @param \DOMNode             $parent
     * @param SerializationContext $context
     * @param string|null          $namespace
     */
    protected function singleElementsToXml(array $names, \DOMNode $parent, SerializationContext $context, $namespace = null)
    {
        foreach ($names as $name) {
            $this->oneElementToXml($name, $parent, $context, $namespace);
        }
    }

    /**
     * @param array|null           $value
     * @param \DOMNode             $node
     * @param SerializationContext $context
     * @param null|string          $nodeName
     * @param null|string          $namespaceUri
     *
     * @throws \LogicException
     */
    protected function manyElementsToXml($value, \DOMNode $node, SerializationContext $context, $nodeName = null, $namespaceUri = null)
    {
        if (false == $value) {
            return;
        }

        if (false == is_array($value)) {
            throw new \LogicException('value must be array or null');
        }

        foreach ($value as $object) {
            if ($object instanceof SamlElementInterface) {
                if ($nodeName) {
                    throw new \LogicException('nodeName should not be specified when serializing array of SamlElementInterface');
                }
                $object->serialize($node, $context);
            } elseif ($nodeName) {
                if ($namespaceUri) {
                    $child = $context->getDocument()->createElementNS($namespaceUri, $nodeName, (string) $object);
                } else {
                    $child = $context->getDocument()->createElement($nodeName, (string) $object);
                }
                $node->appendChild($child);
            } else {
                throw new \LogicException('Can handle only array of AbstractSamlModel or strings with nodeName parameter specified');
            }
        }
    }

    /**
     * @param \DOMElement            $node
     * @param DeserializationContext $context
     * @param string                 $nodeName
     * @param string|null            $namespacePrefix
     * @param string                 $class
     * @param string                 $methodName
     *
     * @throws \LogicException
     */
    protected function manyElementsFromXml(\DOMElement $node, DeserializationContext $context, $nodeName, $namespacePrefix, $class, $methodName)
    {
        if ($namespacePrefix) {
            $query = sprintf('%s:%s', $namespacePrefix, $nodeName);
        } else {
            $query = sprintf('%s', $nodeName);
        }

        foreach ($context->getXpath()->query($query, $node) as $xml) {
            /* @var \DOMElement $xml */
            if ($class) {
                /** @var SamlElementInterface $object */
                $object = new $class();
                if (false == $object instanceof SamlElementInterface) {
                    throw new \LogicException(sprintf("Node '%s' class '%s' must implement SamlElementInterface", $nodeName, $class));
                }
                $object->deserialize($xml, $context);
                $this->{$methodName}($object);
            } else {
                $object = $xml->textContent;
                $this->{$methodName}($object);
            }
        }
    }

    /**
     * @param string      $name
     * @param \DOMElement $element
     *
     * @throws \LogicException
     *
     * @return bool True if property value is not empty and attribute was set to the element
     */
    protected function singleAttributeToXml($name, \DOMElement $element)
    {
        $value = $this->getPropertyValue($name);
        if (null !== $value && '' !== $value) {
            if (is_bool($value)) {
                $element->setAttribute($name, $value ? 'true' : 'false');
            } else {
                $element->setAttribute($name, $value);
            }

            return true;
        }

        return false;
    }

    /**
     * @param array|string[] $names
     * @param \DOMElement    $element
     */
    protected function attributesToXml(array $names, \DOMElement $element)
    {
        foreach ($names as $name) {
            $this->singleAttributeToXml($name, $element);
        }
    }

    /**
     * @param \DOMNode $node
     * @param string   $expectedName
     * @param string   $expectedNamespaceUri
     */
    protected function checkXmlNodeName(\DOMNode &$node, $expectedName, $expectedNamespaceUri)
    {
        if ($node instanceof \DOMDocument) {
            $node = $node->firstChild;
        }
        while ($node && $node instanceof \DOMComment) {
            $node = $node->nextSibling;
        }
        if (null === $node) {
            throw new LightSamlXmlException(sprintf(
                "Unable to find expected '%s' xml node and '%s' namespace",
                $expectedName,
                $expectedNamespaceUri
            ));
        } elseif ($node->localName != $expectedName || $node->namespaceURI != $expectedNamespaceUri) {
            throw new LightSamlXmlException(sprintf(
                "Expected '%s' xml node and '%s' namespace but got node '%s' and namespace '%s'",
                $expectedName,
                $expectedNamespaceUri,
                $node->localName,
                $node->namespaceURI
            ));
        }
    }

    /**
     * @param \DOMElement $node
     * @param string      $attributeName
     */
    protected function singleAttributeFromXml(\DOMElement $node, $attributeName)
    {
        $value = $node->getAttribute($attributeName);
        if ('' !== $value) {
            $setter = 'set'.$attributeName;
            if (method_exists($this, $setter)) {
                $this->{$setter}($value);
            }
        }
    }

    /**
     * @param \DOMElement            $node
     * @param DeserializationContext $context
     * @param string                 $elementName
     * @param string                 $class
     * @param string                 $namespacePrefix
     *
     * @throws \LogicException
     */
    protected function oneElementFromXml(\DOMElement $node, DeserializationContext $context, $elementName, $class, $namespacePrefix)
    {
        if ($namespacePrefix) {
            $query = sprintf('./%s:%s', $namespacePrefix, $elementName);
        } else {
            $query = sprintf('./%s', $elementName);
        }
        $arr = $context->getXpath()->query($query, $node);
        $value = $arr->length > 0 ? $arr->item(0) : null;

        if ($value) {
            $setter = 'set'.$elementName;
            if (false == method_exists($this, $setter)) {
                throw new \LogicException(sprintf(
                    "Unable to find setter for element '%s' in class '%s'",
                    $elementName,
                    get_class($this)
                ));
            }

            if ($class) {
                /** @var AbstractSamlModel $object */
                $object = new $class();
                if (false == $object instanceof \LightSaml\Model\SamlElementInterface) {
                    throw new \LogicException(sprintf(
                        "Specified class '%s' for element '%s' must implement SamlElementInterface",
                        $class,
                        $elementName
                    ));
                }

                $object->deserialize($value, $context);
            } else {
                $object = $value->textContent;
            }

            $this->{$setter}($object);
        }
    }

    /**
     * @param \DOMElement            $node
     * @param DeserializationContext $context
     * @param array                  $options elementName=>class
     */
    protected function singleElementsFromXml(\DOMElement $node, DeserializationContext $context, array $options)
    {
        foreach ($options as $elementName => $info) {
            $this->oneElementFromXml($node, $context, $elementName, $info[1], $info[0]);
        }
    }

    /**
     * @param \DOMElement $node
     * @param array       $attributeNames
     */
    protected function attributesFromXml(\DOMElement $node, array $attributeNames)
    {
        foreach ($attributeNames as $attributeName) {
            $this->singleAttributeFromXml($node, $attributeName);
        }
    }

    /**
     * @param string $name
     *
     * @return mixed
     *
     * @throws \LogicException
     */
    private function getPropertyValue($name)
    {
        if (false !== ($pos = strpos($name, ':'))) {
            $name = substr($name, $pos + 1);
        }
        $getter = 'get'.$name.'String';
        if (false == method_exists($this, $getter)) {
            $getter = 'get'.$name;
        }
        if (false == method_exists($this, $getter)) {
            throw new \LogicException(sprintf(
                "Unable to find getter method for '%s' on '%s'",
                $name,
                get_class($this)
            ));
        }
        $value = $this->{$getter}();

        return $value;
    }
}


<?php

/*
 * This file is part of the LightSAML-Core package.
 *
 * (c) Milos Tomic <tmilos@lightsaml.com>
 *
 * This source file is subject to the MIT license that is bundled
 * with this source code in the file LICENSE.
 */

namespace LightSaml\Model\Assertion;

use LightSaml\Model\Context\DeserializationContext;
use LightSaml\Model\Context\SerializationContext;
use LightSaml\Error\LightSamlException;
use LightSaml\Model\AbstractSamlModel;
use RobRichards\XMLSecLibs\XMLSecurityKey;
use RobRichards\XMLSecLibs\XMLSecEnc;

abstract class EncryptedElementWriter extends EncryptedElement
{
    /** @var \DOMElement */
    protected $encryptedElement;

    /** @var string */
    protected $blockEncryptionAlgorithm = XMLSecurityKey::AES128_CBC;

    /** @var string */
    protected $keyTransportEncryption = XMLSecurityKey::RSA_1_5;

    /**
     * @param string $blockEncryptionAlgorithm
     * @param string $keyTransportEncryption
     */
    public function __construct($blockEncryptionAlgorithm = XMLSecurityKey::AES128_CBC, $keyTransportEncryption = XMLSecurityKey::RSA_1_5)
    {
        $this->blockEncryptionAlgorithm = $blockEncryptionAlgorithm;
        $this->keyTransportEncryption = $keyTransportEncryption;
    }

    /**
     * @param AbstractSamlModel $object
     * @param XMLSecurityKey    $key
     *
     * @return SerializationContext
     */
    public function encrypt(AbstractSamlModel $object, XMLSecurityKey $key)
    {
        $oldKey = $key;
        $key = new XMLSecurityKey($this->keyTransportEncryption, ['type' => 'public']);
        $key->loadKey($oldKey->key);

        $serializationContext = new SerializationContext();
        $object->serialize($serializationContext->getDocument(), $serializationContext);

        $enc = new XMLSecEnc();
        $enc->setNode($serializationContext->getDocument()->firstChild);
        $enc->type = XMLSecEnc::Element;

        switch ($key->type) {
            case XMLSecurityKey::TRIPLEDES_CBC:
            case XMLSecurityKey::AES128_CBC:
            case XMLSecurityKey::AES192_CBC:
            case XMLSecurityKey::AES256_CBC:
                $symmetricKey = $key;
                break;

            case XMLSecurityKey::RSA_1_5:
            case XMLSecurityKey::RSA_SHA1:
            case XMLSecurityKey::RSA_SHA256:
            case XMLSecurityKey::RSA_SHA384:
            case XMLSecurityKey::RSA_SHA512:
            case XMLSecurityKey::RSA_OAEP_MGF1P:
                $symmetricKey = new XMLSecurityKey($this->blockEncryptionAlgorithm);
                $symmetricKey->generateSessionKey();

                $enc->encryptKey($key, $symmetricKey);

                break;

            default:
                throw new LightSamlException(sprintf('Unknown key type for encryption: "%s"', $key->type));
        }

        $this->encryptedElement = $enc->encryptNode($symmetricKey);

        return $serializationContext;
    }

    /**
     * @param \DOMNode             $parent
     * @param SerializationContext $context
     *
     * @return \DOMElement
     */
    abstract protected function createRootElement(\DOMNode $parent, SerializationContext $context);

    /**
     * @param \DOMNode             $parent
     * @param SerializationContext $context
     *
     * @return void
     */
    public function serialize(\DOMNode $parent, SerializationContext $context)
    {
        if (null === $this->encryptedElement) {
            throw new LightSamlException('Encrypted element missing');
        }

        $root = $this->createRootElement($parent, $context);

        $root->appendChild($context->getDocument()->importNode($this->encryptedElement, true));
    }

    /**
     * @param \DOMNode               $node
     * @param DeserializationContext $context
     */
    public function deserialize(\DOMNode $node, DeserializationContext $context)
    {
        throw new \LogicException('EncryptedElementWriter can not be used for deserialization');
    }
}


<?php

/*
 * This file is part of the LightSAML-Core package.
 *
 * (c) Milos Tomic <tmilos@lightsaml.com>
 *
 * This source file is subject to the MIT license that is bundled
 * with this source code in the file LICENSE.
 */

namespace LightSaml\Context\Profile\Helper;

use LightSaml\Action\ActionInterface;
use LightSaml\Context\ContextInterface;
use LightSaml\Context\Profile\ProfileContext;

abstract class LogHelper
{
    /**
     * @param ContextInterface $context
     * @param ActionInterface  $action
     * @param array            $extraData
     *
     * @return array
     */
    public static function getActionContext(ContextInterface $context, ActionInterface $action, array $extraData = null)
    {
        return self::getContext($context, $action, $extraData, false);
    }

    /**
     * @param ContextInterface $context
     * @param ActionInterface  $action
     * @param array            $extraData
     *
     * @return array
     */
    public static function getActionErrorContext(ContextInterface $context, ActionInterface $action, array $extraData = null)
    {
        return self::getContext($context, $action, $extraData, true);
    }

    /**
     * @param ContextInterface $context
     * @param ActionInterface  $action
     * @param array            $extraData
     * @param bool             $logWholeContext
     *
     * @return array
     */
    private static function getContext(ContextInterface $context, ActionInterface $action = null, array $extraData = null, $logWholeContext = false)
    {
        $topContext = $context->getTopParent();
        $result = array();
        if ($topContext instanceof ProfileContext) {
            $result['profile_id'] = $topContext->getProfileId();
            $result['own_role'] = $topContext->getOwnRole();
        }
        if ($action) {
            $result['action'] = get_class($action);
        }
        $result['top_context_id'] = spl_object_hash($topContext);

        if ($logWholeContext) {
            $result['top_context'] = $topContext;
        }
        if ($extraData) {
            $result = array_merge($result, $extraData);
        }

        return $result;
    }
}


<?php

namespace FINDOLOGIC\Export\Helpers;

use DOMDocument;
use DOMElement;

class XMLHelper
{
    /**
     * Shortcut for creating an XML element.
     *
     * @param DOMDocument $document The document used to create the element. It will NOT be modified.
     * @param string $name Name of the element.
     * @param array $attributes String-to-string mapping of attributes to set on the element.
     * @return DOMElement The newly constructed independent DOM element.
     */
    public static function createElement(DOMDocument $document, $name, array $attributes = []): DOMElement
    {
        $element = $document->createElement($name);

        foreach ($attributes as $attribName => $attribValue) {
            $element->setAttribute($attribName, $attribValue);
        }

        return $element;
    }

    /**
     * Shortcut for creating an XML element that contains CDATA-wrapped text.
     *
     * @param DOMDocument $document The document used to create the element. It will NOT be modified.
     * @param string $name Name of the element.
     * @param string $text The text body of the document.
     * @param array $attributes String-to-string mapping of attributes to set on the element.
     * @return DOMElement The newly constructed independent DOM element.
     */
    public static function createElementWithText(
        DOMDocument $document,
        string $name,
        string $text,
        array $attributes = []
    ): DOMElement {
        $element = self::createElement($document, $name, $attributes);
        $wrappedText = $document->createCDATASection($text);
        $element->appendChild($wrappedText);

        return $element;
    }
}


<?php

namespace FINDOLOGIC\Export\XML;

use BadMethodCallException;
use DOMDocument;
use DOMElement;
use FINDOLOGIC\Export\Data\Attribute;
use FINDOLOGIC\Export\Data\Image;
use FINDOLOGIC\Export\Data\Item;
use FINDOLOGIC\Export\Data\Usergroup;
use FINDOLOGIC\Export\Exceptions\BaseImageMissingException;
use FINDOLOGIC\Export\Exceptions\ImagesWithoutUsergroupMissingException;
use FINDOLOGIC\Export\Exceptions\UnsupportedValueException;
use FINDOLOGIC\Export\Helpers\XMLHelper;

class XMLItem extends Item
{
    /**
     * @inheritdoc
     */
    public function getCsvFragment(array $availableProperties = []): void
    {
        throw new BadMethodCallException('XMLItem does not implement CSV export.');
    }

    /**
     * @SuppressWarnings(PHPMD.StaticAccess)
     * @inheritdoc
     */
    public function getDomSubtree(DOMDocument $document): DOMElement
    {
        $itemElem = XMLHelper::createElement($document, 'item', ['id' => $this->id]);
        $document->appendChild($itemElem);

        $itemElem->appendChild($this->name->getDomSubtree($document));
        $itemElem->appendChild($this->summary->getDomSubtree($document));
        $itemElem->appendChild($this->description->getDomSubtree($document));
        $itemElem->appendChild($this->price->getDomSubtree($document));
        $itemElem->appendChild($this->url->getDomSubtree($document));
        $itemElem->appendChild($this->bonus->getDomSubtree($document));
        $itemElem->appendChild($this->salesFrequency->getDomSubtree($document));
        $itemElem->appendChild($this->dateAdded->getDomSubtree($document));
        $itemElem->appendChild($this->sort->getDomSubtree($document));
        $itemElem->appendChild($this->keywords->getDomSubtree($document));
        $itemElem->appendChild($this->ordernumbers->getDomSubtree($document));

        $itemElem->appendChild($this->buildProperties($document));
        $itemElem->appendChild($this->buildAttributes($document));
        $itemElem->appendChild($this->buildImages($document));
        $itemElem->appendChild($this->buildUsergroups($document));

        return $itemElem;
    }

    /**
     * @SuppressWarnings(PHPMD.StaticAccess)
     * @param DOMDocument $document
     * @return DOMElement
     */
    private function buildProperties(DOMDocument $document): DOMElement
    {
        $allProps = XMLHelper::createElement($document, 'allProperties');

        foreach ($this->properties as $usergroup => $usergroupSpecificProperties) {
            $usergroupPropsElem = XMLHelper::createElement($document, 'properties');
            if ($usergroup) {
                $usergroupPropsElem->setAttribute('usergroup', $usergroup);
            }
            $allProps->appendChild($usergroupPropsElem);

            foreach ($usergroupSpecificProperties as $key => $value) {
                $propertyElem = XMLHelper::createElement($document, 'property');
                $usergroupPropsElem->appendChild($propertyElem);

                $keyElem = XMLHelper::createElementWithText($document, 'key', $key);
                $propertyElem->appendChild($keyElem);

                $valueElem = XMLHelper::createElementWithText($document, 'value', $value);
                $propertyElem->appendChild($valueElem);
            }
        }

        return $allProps;
    }

    /**
     * @SuppressWarnings(PHPMD.StaticAccess)
     * @param DOMDocument $document
     * @return DOMElement
     */
    private function buildAttributes(DOMDocument $document): DOMElement
    {
        $allAttributes = XMLHelper::createElement($document, 'allAttributes');

        $attributes = XMLHelper::createElement($document, 'attributes');
        $allAttributes->appendChild($attributes);

        /**
         * @var string $key
         * @var Attribute $attribute
         */
        foreach ($this->attributes as $key => $attribute) {
            $attributes->appendChild($attribute->getDomSubtree($document));
        }

        return $allAttributes;
    }

    /**
     * @SuppressWarnings(PHPMD.StaticAccess)
     * @param DOMDocument $document
     * @return DOMElement
     */
    private function buildImages(DOMDocument $document): DOMElement
    {
        $allImagesElem = XMLHelper::createElement($document, 'allImages');

        if ($this->images) {
            if (array_key_exists("", $this->images)) {
                foreach ($this->images as $usergroup => $images) {
                    $usergroupImagesElem = XMLHelper::createElement($document, 'images');
                    if ($usergroup) {
                        $usergroupImagesElem->setAttribute('usergroup', $usergroup);
                    }

                    $allImagesElem->appendChild($usergroupImagesElem);

                    if (XMLItem::validateImages($images)) {
                        /** @var Image $image */
                        foreach ($images as $image) {
                            $usergroupImagesElem->appendChild($image->getDomSubtree($document));
                        }
                    }
                }
            } else {
                throw new ImagesWithoutUsergroupMissingException();
            }
        }

        return $allImagesElem;
    }

    /**
     * @SuppressWarnings(PHPMD.StaticAccess)
     * @param DOMDocument $document
     * @return DOMElement
     */
    private function buildUsergroups(DOMDocument $document): DOMElement
    {
        $usergroups = XMLHelper::createElement($document, 'usergroups');

        /** @var Usergroup $usergroup */
        foreach ($this->usergroups as $usergroup) {
            $usergroups->appendChild($usergroup->getDomSubtree($document));
        }

        return $usergroups;
    }

    /**
     * Checks if there is at least one image of type default
     *
     * @param array $images The images to validate.
     * @return boolean Whether the images are valid or not.
     */
    private static function validateImages(array $images): bool
    {
        $valid = false;

        foreach ($images as $image) {
            if ($image->getType() === Image::TYPE_DEFAULT) {
                $valid = true;
                break;
            }
        }

        if (!$valid) {
            throw new BaseImageMissingException();
        }

        return $valid;
    }

    public function getInsteadPrice(): void
    {
        throw new UnsupportedValueException('insteadPrice');
    }

    public function setInsteadPrice(float $insteadPrice): void
    {
        throw new UnsupportedValueException('insteadPrice');
    }

    public function getMaxPrice(): void
    {
        throw new UnsupportedValueException('maxPrice');
    }

    public function setMaxPrice(float $insteadPrice): void
    {
        throw new UnsupportedValueException('maxPrice');
    }

    public function getTaxRate(): void
    {
        throw new UnsupportedValueException('taxRate');
    }

    public function setTaxRate(float $insteadPrice): void
    {
        throw new UnsupportedValueException('taxRate');
    }
}


<?php

namespace FINDOLOGIC\Export\Helpers;

use FINDOLOGIC\Export\Exceptions\AttributeKeyLengthException;
use FINDOLOGIC\Export\Exceptions\AttributeValueLengthException;
use FINDOLOGIC\Export\Exceptions\BadPropertyKeyException;
use FINDOLOGIC\Export\Exceptions\EmptyValueNotAllowedException;
use FINDOLOGIC\Export\Exceptions\GroupNameLengthException;
use FINDOLOGIC\Export\Exceptions\InvalidUrlException;
use FINDOLOGIC\Export\Exceptions\ItemIdLengthException;

/**
 * Class DataHelper
 * @package FINDOLOGIC\Export\Helpers
 *
 * Collection of helper methods for data elements.
 */
class DataHelper
{
    /*
     * Internal character limit for attribute values.
     */
    private const ATTRIBUTE_CHARACTER_LIMIT = 16383;

    /*
     * Internal character limit for item id.
     */
    private const ITEM_ID_CHARACTER_LIMIT = 255;

    /*
     * Internal character limit for group names of CSV export.
     */
    private const CSV_GROUP_CHARACTER_LIMIT = 255;

    /*
     * Internal character limit for attribute key names of CSV export.
     */
    private const CSV_ATTRIBUTE_KEY_CHARACTER_LIMIT = 247;

    /**
     * Checks if the provided value is empty.
     *
     * @param string $valueName Name of the value, for better error reporting.
     * @param string|int|float $value The value to check. Regardless of type, it is coerced into a string.
     * @return string Returns the value if not empty.
     */
    public static function checkForEmptyValue(string $valueName, $value): string
    {
        $value = trim($value);

        if ($value === '') {
            throw new EmptyValueNotAllowedException($valueName);
        }

        return $value;
    }

    /**
     * Checks if the provided input complies to a FINDOLOGIC valid url.
     * URL needs to have a http[s] schema.
     * See https://docs.findologic.com/doku.php?id=export_patterns:xml#urls
     *
     * @param string $url The input to check.
     * @throws InvalidUrlException If the input is no url.
     * @return string Returns the url if valid.
     */
    public static function validateUrl(string $url): string
    {
        if (!filter_var($url, FILTER_VALIDATE_URL) || !preg_match('/http[s]?:\/\/.*/', $url)) {
            throw new InvalidUrlException();
        }

        return $url;
    }

    /**
     * Verifies that property keys for use in CSV export don't contain characters that could break the format fatally.
     *
     * @param string $propertyKey The property key to check.
     * @throw BadPropertyKeyException In case the property key contains dangerous characters.
     */
    public static function checkForIllegalCsvPropertyKeys(string $propertyKey): void
    {
        if (strpos($propertyKey, "\t") !== false || strpos($propertyKey, "\n") !== false) {
            throw new BadPropertyKeyException($propertyKey);
        }
    }

    /**
     * @param string $attributeName Attribute name to output in exception.
     * @param string $attributeValue Attribute value to check if it exceeds character limit.
     */
    public static function checkAttributeValueNotExceedingCharacterLimit($attributeName, $attributeValue): void
    {
        if (mb_strlen($attributeValue) > self::ATTRIBUTE_CHARACTER_LIMIT) {
            throw new AttributeValueLengthException($attributeName, self::ATTRIBUTE_CHARACTER_LIMIT);
        }
    }

    /**
     * @param string $id Attribute value to check if it exceeds character limit.
     */
    public static function checkItemIdNotExceedingCharacterLimit($id)
    {
        if (mb_strlen($id) > self::ITEM_ID_CHARACTER_LIMIT) {
            throw new ItemIdLengthException($id, self::ITEM_ID_CHARACTER_LIMIT);
        }
    }

    /**
     * @param string $group Group name to check if it exceeds character limit.
     */
    public static function checkCsvGroupNameNotExceedingCharacterLimit($group)
    {
        if (mb_strlen($group) > self::CSV_GROUP_CHARACTER_LIMIT) {
            throw new GroupNameLengthException($group, self::CSV_GROUP_CHARACTER_LIMIT);
        }
    }

    /**
     * @param string $group Group name to check if it exceeds character limit.
     */
    public static function checkCsvAttributeKeyNotExceedingCharacterLimit($group)
    {
        if (mb_strlen($group) > self::CSV_ATTRIBUTE_KEY_CHARACTER_LIMIT) {
            throw new AttributeKeyLengthException($group, self::CSV_ATTRIBUTE_KEY_CHARACTER_LIMIT);
        }
    }
}


<?php

namespace FINDOLOGIC\Export;

use FINDOLOGIC\Export\CSV\CSVExporter;
use FINDOLOGIC\Export\Data\Item;
use FINDOLOGIC\Export\XML\XMLExporter;
use InvalidArgumentException;

abstract class Exporter
{
    /**
     * XML-based export format.
     *
     * @see https://docs.findologic.com/doku.php?id=export_patterns:xml
     */
    public const TYPE_XML = 0;

    /**
     * CSV-based export format. Does not support usergroups.
     *
     * @see https://docs.findologic.com/doku.php?id=export_patterns:csv
     */
    public const TYPE_CSV = 1;

    /**
     * Creates an exporter for the desired output format.
     *
     * @param int $type The type of export format to choose. Must be either Exporter::TYPE_XML or Exporter::TYPE_CSV.
     * @param int $itemsPerPage Number of items being exported at once. Respecting this parameter is at the exporter
     *      implementation's discretion.
     * @param array $csvProperties Properties/extra columns for CSV export. Has no effect for XML export.
     * @return Exporter The exporter for the desired output format.
     */
    public static function create(int $type, int $itemsPerPage = 20, array $csvProperties = []): Exporter
    {
        if ($itemsPerPage < 1) {
            throw new InvalidArgumentException('At least one item must be exported per page.');
        }

        switch ($type) {
            case self::TYPE_XML:
                $exporter = new XMLExporter($itemsPerPage);
                break;
            case self::TYPE_CSV:
                $exporter = new CSVExporter($itemsPerPage, $csvProperties);
                break;
            default:
                throw new InvalidArgumentException('Unsupported exporter type.');
        }

        return $exporter;
    }

    protected $itemsPerPage;

    protected function __construct($itemsPerPage)
    {
        $this->itemsPerPage = $itemsPerPage;
    }

    /**
     * Turns the provided items into their serialized form.
     *
     * @param array $items Array of items to serialize. All of them are serialized, regardless of $start and $total.
     * @param int $start Assuming that $items is a fragment of the total, this is the global index of the first item in
     *      $items.
     * @param int $count The number of items requested for this export step. Actual number of items can be smaller due
     *      to errors, and can not be greater than the requested count, because that would indicate that the requested
     *      count is ignored when generating items. This value is ignored when using CSV exporter.
     * @param int $total The global total of items that could be exported. This value is ignored when using CSV
     *      exporter.
     * @return string The items in serialized form.
     */
    abstract public function serializeItems(array $items, int $start, int $count, int $total): string;

    /**
     * Like serializeItems(), but the output is written to filesystem instead of being returned.
     *
     * @param string $targetDirectory The directory to which the file is written. The filename is at the exporter
     *      implementation's discretion.
     * @param array $items Array of items to serialize. All of them are serialized, regardless of $start and $total.
     * @param int $start Assuming that $items is a fragment of the total, this is the global index of the first item in
     *      $items.
     * @param int $count The number of items requested for this export step. Actual number of items can be smaller due
     *      to errors, and can not be greater than the requested count, because that would indicate that the requested
     *      count is ignored when generating items. This value is ignored when using CSV exporter.
     * @param int $total The global total of items that could be exported. This value is ignored when using CSV
     *      exporter.
     * @return string Full path of the written file.
     */
    abstract public function serializeItemsToFile(
        string $targetDirectory,
        array $items,
        int $start,
        int $count,
        int $total
    ): string;

    /**
     * Creates an export format-specific item instance.
     *
     * @param string $id Unique ID of the item.
     * @return Item The newly generated item.
     */
    abstract public function createItem($id): Item;
}


<?php

namespace FINDOLOGIC\Export\Data;

use FINDOLOGIC\Export\Exceptions\DuplicateValueForUsergroupException;
use FINDOLOGIC\Export\Exceptions\PropertyKeyNotAllowedException;
use FINDOLOGIC\Export\Helpers\DataHelper;

class Property
{
    /**
     * Reserved property keys for internal use which would be overwritten when importing
     *
     * /image\d+/: Image URLs of type default.
     * /thumbnail\d+/: Image URLs of type thumbnail.
     * /ordernumber/: The products first exported ordernumber.
     */
    private const RESERVED_PROPERTY_KEYS = [
        "/image\d+/",
        "/thumbnail\d+/",
        "/ordernumber/"
    ];

    /** @var string */
    private $key;

    /** @var array */
    private $values;

    /**
     * Property constructor.
     *
     * @SuppressWarnings(PHPMD.StaticAccess)
     * @param string $key The property key.
     * @param array $values The values of the property.
     */
    public function __construct(string $key, array $values = [])
    {
        foreach (self::RESERVED_PROPERTY_KEYS as $reservedPropertyKey) {
            if (preg_match($reservedPropertyKey, $key)) {
                throw new PropertyKeyNotAllowedException($key);
            }
        }

        $this->key = DataHelper::checkForEmptyValue('propertyKey', $key);
        $this->setValues($values);
    }

    public function getKey(): string
    {
        return $this->key;
    }

    /**
     * Add a value to the property element.
     *
     * @SuppressWarnings(PHPMD.StaticAccess)
     * @param string $value The value to add to the property element.
     * @param string|null $usergroup The usergroup of the property value.
     */
    public function addValue(string $value, ?string $usergroup = null): void
    {
        if (array_key_exists($usergroup, $this->getAllValues())) {
            throw new DuplicateValueForUsergroupException($this->getKey(), $usergroup);
        }

        $this->values[$usergroup] = DataHelper::checkForEmptyValue('propertyValue', $value);
    }

    protected function setValues(array $values): void
    {
        $this->values = [];

        /**
         * As we can not check if the values of the given array is associative,
         * we trigger a notice if the array keys are not a string.
         */
        array_walk($values, function (/** @noinspection PhpUnusedParameterInspection */ $item, $key) {
            if (!is_string($key)) {
                $format = 'Property values have to be associative, like $key => $value. The key "%s" has to be a ' .
                    'string, integer given.';
                trigger_error(sprintf($format, $key), E_USER_WARNING);
            }
        });

        foreach ($values as $usergroup => $value) {
            $this->addValue($value, $usergroup);
        }
    }

    public function getAllValues(): array
    {
        return $this->values;
    }
}


<?php

namespace FINDOLOGIC\Export\XML;

use DOMDocument;
use FINDOLOGIC\Export\Constant;
use FINDOLOGIC\Export\Exceptions\ItemsExceedCountValueException;
use FINDOLOGIC\Export\Exceptions\XMLSchemaViolationException;
use FINDOLOGIC\Export\Helpers\XMLHelper;

class Page
{
    private $items;
    private $start;
    private $count;
    private $total;

    public function __construct(int $start, int $count, int $total)
    {
        $this->start = $start;
        $this->count = $count;
        $this->total = $total;
        $this->items = [];
    }

    public function addItem(XMLItem $item): void
    {
        array_push($this->items, $item);
    }

    public function setAllItems(array $items): void
    {
        $this->items = [];

        foreach ($items as $item) {
            $this->addItem($item);
        }
    }

    /**
     * @SuppressWarnings(PHPMD.StaticAccess)
     */
    public function getXml(): DOMDocument
    {
        if (count($this->items) > $this->count) {
            throw new ItemsExceedCountValueException();
        }

        $document = new DOMDocument('1.0', 'utf-8');
        $root = XMLHelper::createElement($document, 'findologic', ['version' => '1.0']);
        $document->appendCHild($root);

        $items = XMLHelper::createElement($document, 'items', [
            'start' => $this->start,
            'count' => $this->count,
            'total' => $this->total
        ]);
        $root->appendChild($items);

        /** @var XMLItem $item */
        foreach ($this->items as $item) {
            $itemDom = $item->getDomSubtree($document);
            $items->appendChild($itemDom);
        }

        $this->validateWithSchema($document);

        return $document;
    }

    /**
     * Validates the export page against the schema. In case of violations, an exception is thrown.
     *
     * @param DOMDocument $document The document to validate.
     */
    private function validateWithSchema(DOMDocument $document): void
    {
        $validationErrors = [];
        set_error_handler(function (/** @noinspection PhpUnusedParameterInspection */ $errno, $errstr)
 use (&$validationErrors) {
            array_push($validationErrors, $errstr);
        });

        $isValid = $document->schemaValidate(Constant::$XSD_SCHEMA_PATH);
        restore_error_handler();

        if (!$isValid) {
            throw new XMLSchemaViolationException($validationErrors);
        }
    }
}


<?php

namespace FINDOLOGIC\Export\Data;

use DateTime;
use DOMDocument;
use FINDOLOGIC\Export\Exceptions\EmptyElementsNotAllowedException;
use FINDOLOGIC\Export\Helpers\Serializable;
use FINDOLOGIC\Export\Helpers\DataHelper;

abstract class Item implements Serializable
{
    /** @var string */
    protected $id;

    /** @var Name */
    protected $name;

    /** @var Summary */
    protected $summary;

    /** @var Description */
    protected $description;

    /** @var Price */
    protected $price;

    /** @var float */
    protected $insteadPrice;

    /** @var float */
    protected $maxPrice;

    /** @var float */
    protected $taxRate;

    /** @var Url */
    protected $url;

    /** @var Bonus */
    protected $bonus;

    /** @var SalesFrequency */
    protected $salesFrequency;

    /** @var DateAdded */
    protected $dateAdded;

    /** @var Sort */
    protected $sort;

    /** @var AllKeywords */
    protected $keywords;

    /** @var AllOrdernumbers */
    protected $ordernumbers;

    protected $properties = [];

    protected $attributes = [];

    protected $images = [];

    protected $usergroups = [];

    public function __construct($id)
    {
        $this->setId($id);

        $this->name = new Name();
        $this->summary = new Summary();
        $this->description = new Description();
        $this->url = new Url();
        $this->bonus = new Bonus();
        $this->salesFrequency = new SalesFrequency();
        $this->dateAdded = new DateAdded();
        $this->sort = new Sort();
        $this->keywords = new AllKeywords();
        $this->ordernumbers = new AllOrdernumbers();
    }

    /**
     * @return string
     */
    public function getId(): string
    {
        return $this->id;
    }

    /**
     * @param string $id The id of the item to set
     */
    public function setId(string $id): void
    {
        DataHelper::checkItemIdNotExceedingCharacterLimit($id);
        $this->id = $id;
    }

    public function getName(): Name
    {
        return $this->name;
    }

    /**
     * @param Name $name The name element to add to the item.
     */
    public function setName(Name $name): void
    {
        $this->name = $name;
    }

    /**
     * Shortcut to easily add the name of the item.
     *
     * @param string $name The name of the item.
     * @param string $usergroup The usergroup of the name element.
     */
    public function addName(string $name, string $usergroup = ''): void
    {
        $this->name->setValue($name, $usergroup);
    }

    public function getSummary(): Summary
    {
        return $this->summary;
    }

    /**
     * @param Summary $summary The summary element to add to the item.
     */
    public function setSummary(Summary $summary): void
    {
        $this->summary = $summary;
    }

    /**
     * Shortcut to easily add the summary of the item.
     *
     * @param string $summary The summary of the item.
     * @param string $usergroup The usergroup of the summary.
     */
    public function addSummary(string $summary, string $usergroup = ''): void
    {
        $this->summary->setValue($summary, $usergroup);
    }

    public function getDescription(): Description
    {
        return $this->description;
    }

    /**
     * @param Description $description The description element to add to the item.
     */
    public function setDescription(Description $description): void
    {
        $this->description = $description;
    }

    /**
     * Shortcut to easily add the description of the item.
     *
     * @param string $description The description of the item.
     * @param string $usergroup The usergroup of the description.
     */
    public function addDescription(string $description, string $usergroup = ''): void
    {
        $this->description->setValue($description, $usergroup);
    }

    public function getPrice(): Price
    {
        return $this->price;
    }

    /**
     * @param Price $price The price element to add to the item.
     */
    public function setPrice(Price $price): void
    {
        $this->price = $price;
    }

    /**
     * Shortcut to easily add the price of the item.
     *
     * @param string $price The price of the item.
     * @param string $usergroup The usergroup of the price.
     */
    public function addPrice($price, $usergroup = ''): void
    {
        if ($this->price === null) {
            $this->price = new Price();
        }

        $this->price->setValue($price, $usergroup);
    }

    public function getInsteadPrice()
    {
        return $this->insteadPrice;
    }

    /**
     * Set the instead price of the item. This is only relevant for CSV export type.
     *
     * @param float $insteadPrice The instead price of the item.
     */
    public function setInsteadPrice(float $insteadPrice): void
    {
        $this->insteadPrice = $insteadPrice;
    }

    public function getMaxPrice()
    {
        return $this->maxPrice;
    }

    /**
     * Set the max price of the item. This is only relevant for CSV export type.
     *
     * @param float $maxPrice The instead price of the item.
     */
    public function setMaxPrice(float $maxPrice): void
    {
        $this->maxPrice = $maxPrice;
    }

    public function getTaxRate()
    {
        return $this->taxRate;
    }

    /**
     * Set the tax rate of the item. This is only relevant for CSV export type.
     *
     * @param float $taxRate The tax rate of the item.
     */
    public function setTaxRate(float $taxRate): void
    {
        $this->taxRate = $taxRate;
    }

    public function getUrl(): Url
    {
        return $this->url;
    }

    /**
     * @param Url $url The url element to add to the item.
     */
    public function setUrl(Url $url): void
    {
        $this->url = $url;
    }

    public function addUrl(string $url, string $usergroup = ''): void
    {
        $this->url->setValue($url, $usergroup);
    }

    public function getBonus(): Bonus
    {
        return $this->bonus;
    }

    /**
     * @param Bonus $bonus The bonus element to add to the item.
     */
    public function setBonus(Bonus $bonus): void
    {
        $this->bonus = $bonus;
    }

    /**
     * Shortcut to easily add the bonus of the item. The value must be a numeric.
     *
     * @param float $bonus The bonus value of the item.
     * @param string $usergroup The usergroup of the bonus value.
     */
    public function addBonus(float $bonus, string $usergroup = ''): void
    {
        $this->bonus->setValue($bonus, $usergroup);
    }

    public function getSalesFrequency(): SalesFrequency
    {
        return $this->salesFrequency;
    }

    /**
     * @param SalesFrequency $salesFrequency The sales frequency element to add to the item.
     */
    public function setSalesFrequency(SalesFrequency $salesFrequency): void
    {
        $this->salesFrequency = $salesFrequency;
    }

    /**
     * Shortcut to easily add the sales frequency of the item. The value must be a positive integer.
     *
     * @param int $salesFrequency The sales frequency of the item.
     * @param string $usergroup The usergroup of the sales frequency.
     */
    public function addSalesFrequency(int $salesFrequency, string $usergroup = ''): void
    {
        $this->salesFrequency->setValue($salesFrequency, $usergroup);
    }

    public function getDateAdded(): DateAdded
    {
        return $this->dateAdded;
    }

    /**
     * @param DateAdded $dateAdded The date added element to add to the item.
     */
    public function setDateAdded(DateAdded $dateAdded): void
    {
        $this->dateAdded = $dateAdded;
    }

    /**
     * Shortcut to easily add the date added value of the item.
     *
     * @param DateTime $dateAdded The date on which the item was added to the ecommerce system.
     * @param string $usergroup The usergroup of the date added value.
     */
    public function addDateAdded(DateTime $dateAdded, string $usergroup = ''): void
    {
        $this->dateAdded->setDateValue($dateAdded, $usergroup);
    }

    public function getSort(): Sort
    {
        return $this->sort;
    }

    /**
     * @param Sort $sort The sort element to add to the item.
     */
    public function setSort(Sort $sort): void
    {
        $this->sort = $sort;
    }

    /**
     * Shortcut to easily add the sort value of the item.
     *
     * @param int $sort The sort value of the item.
     * @param string $usergroup The usergroup of the sort value.
     */
    public function addSort(int $sort, string $usergroup = ''): void
    {
        $this->sort->setValue($sort, $usergroup);
    }

    /**
     * @param Property $property The property element to add to the item.
     */
    public function addProperty(Property $property): void
    {
        if (count($property->getAllValues()) === 0) {
            throw new EmptyElementsNotAllowedException('Property', $property->getKey());
        }

        foreach ($property->getAllValues() as $usergroup => $value) {
            if (!array_key_exists($usergroup, $this->properties)) {
                $this->properties[$usergroup] = [];
            }
            // No need to check if there are duplicate values for a single property and usergroup, because
            // Property::addValue() already takes care of that.

            $this->properties[$usergroup][$property->getKey()] = $value;
        }
    }

    /**
     * @param Attribute $attribute The attribute element to add to the item.
     */
    public function addAttribute(Attribute $attribute): void
    {
        if (count($attribute->getValues()) === 0) {
            throw new EmptyElementsNotAllowedException('Attribute', $attribute->getKey());
        }

        $this->attributes[$attribute->getKey()] = $attribute;
    }

    /**
     * @param Image $image The image element to add to the item.
     */
    public function addImage(Image $image): void
    {
        if (!array_key_exists($image->getUsergroup(), $this->images)) {
            $this->images[$image->getUsergroup()] = [];
        }

        array_push($this->images[$image->getUsergroup()], $image);
    }

    /**
     * @param array $images Array of image elements which should be added to the item.
     */
    public function setAllImages(array $images): void
    {
        foreach ($images as $image) {
            $this->addImage($image);
        }
    }

    /**
     * @param Ordernumber $ordernumber The ordernumber element to add to the item.
     */
    public function addOrdernumber(Ordernumber $ordernumber): void
    {
        $this->ordernumbers->addValue($ordernumber);
    }

    /**
     * @param array $ordernumbers Array of ordernumber elements which should be added to the item.
     */
    public function setAllOrdernumbers(array $ordernumbers): void
    {
        $this->ordernumbers->setAllValues($ordernumbers);
    }

    /**
     * @param Keyword $keyword The keyword element to add to the item.
     */
    public function addKeyword(Keyword $keyword): void
    {
        $this->keywords->addValue($keyword);
    }

    /**
     * @param array $keywords Array of keyword elements which should be added to the item.
     */
    public function setAllKeywords(array $keywords): void
    {
        $this->keywords->setAllValues($keywords);
    }

    /**
     * @param Usergroup $usergroup The usergroup element to add to the item.
     */
    public function addUsergroup(Usergroup $usergroup): void
    {
        array_push($this->usergroups, $usergroup);
    }

    /**
     * @param array $usergroups Array of usergroup elements which should be added to the item.
     */
    public function setAllUsergroups(array $usergroups): void
    {
        $this->usergroups = $usergroups;
    }

    /**
     * @inheritdoc
     */
    abstract public function getDomSubtree(DOMDocument $document);
}


<?php

namespace FINDOLOGIC\Export\Helpers;

use DOMDocument;
use DOMElement;
use FINDOLOGIC\Export\Exceptions\EmptyValueNotAllowedException;

/**
 * Class UsergroupAwareSimpleValue
 * @package FINDOLOGIC\Export\Helpers
 *
 * Simple values that can differ per usergroup, but have one value at most for each.
 */
abstract class UsergroupAwareSimpleValue implements Serializable, NameAwareValue
{
    /** @var string */
    private $collectionName;

    /** @var string */
    private $itemName;

    /** @var array */
    protected $values = [];

    public function __construct($collectionName, $itemName)
    {
        $this->collectionName = $collectionName;
        $this->itemName = $itemName;
    }

    public function getValues(): array
    {
        return $this->values;
    }

    /**
     * @SuppressWarnings(PHPMD.StaticAccess)
     * @param string|int|float $value The value of the element.
     * @param string $usergroup The usergroup of the element.
     */
    public function setValue($value, string $usergroup = ''): void
    {
        $this->values[$usergroup] = $this->validate($value);
    }

    /**
     * Validates given value.
     * Basic implementation is validating against an empty string,
     * but is overridden when checking values more specific.
     *
     * When valid returns given value.
     * When not valid an exception is thrown.
     *
     * @param string|int $value Validated value.
     * @return string
     * @throws EmptyValueNotAllowedException
     */
    protected function validate($value)
    {
        $value = trim($value);

        if ($value === '') {
            throw new EmptyValueNotAllowedException($this->getValueName());
        }

        return $value;
    }

    /**
     * @SuppressWarnings(PHPMD.StaticAccess)
     * @inheritdoc
     */
    public function getDomSubtree(DOMDocument $document): DOMElement
    {
        $collectionElem = XMLHelper::createElement($document, $this->collectionName);

        foreach ($this->getValues() as $usergroup => $value) {
            $itemElem = XMLHelper::createElementWithText($document, $this->itemName, $value);
            $collectionElem->appendChild($itemElem);

            if ($usergroup !== '') {
                $itemElem->setAttribute('usergroup', $usergroup);
            }
        }

        return $collectionElem;
    }

    /**
     * @inheritdoc
     */
    public function getCsvFragment(array $availableProperties = []): string
    {
        $value = '';

        if (array_key_exists('', $this->getValues())) {
            $value = $this->getValues()[''];
        }

        return $value;
    }
}


<?php

/**
 * This file is part of Gitonomy.
 *
 * (c) Alexandre Salomé <alexandre.salome@gmail.com>
 * (c) Julien DIDIER <genzo.wm@gmail.com>
 *
 * This source file is subject to the MIT license that is bundled
 * with this source code in the file LICENSE.
 */
namespace Gitonomy\Git;

use Gitonomy\Git\Exception\InvalidArgumentException;
use Gitonomy\Git\Exception\LogicException;

/**
 * Hooks collection, aggregated by repository.
 *
 * @author Alexandre Salomé <alexandre.salome@gmail.com>
 */
class Hooks
{
    /**
     * @var Gitonomy\Git\Repository
     */
    protected $repository;

    /**
     * @var Repository
     */
    public function __construct(Repository $repository)
    {
        $this->repository = $repository;
    }

    /**
     * Tests if repository has a given hook.
     *
     * @param string $name Name of the hook
     *
     * @return bool
     */
    public function has($name)
    {
        return file_exists($this->getPath($name));
    }

    /**
     * Fetches content of a hook.
     *
     * @param string $name Name of the hook
     *
     * @return string Content of the hook
     *
     * @throws InvalidArgumentException Hook does not exist
     */
    public function get($name)
    {
        if (!$this->has($name)) {
            throw new InvalidArgumentException(sprintf('Hook named "%s" is not present', $name));
        }

        return file_get_contents($this->getPath($name));
    }

    /**
     * Insert a hook in repository using a symlink.
     *
     * @param string $name Name of the hook to insert
     * @param string $file Target of symlink
     *
     * @throws LogicException   Hook is already present
     * @throws RuntimeException Error on symlink creation
     */
    public function setSymlink($name, $file)
    {
        if ($this->has($name)) {
            throw new LogicException(sprintf('A hook "%s" is already defined', $name));
        }

        $path = $this->getPath($name);
        if (false === symlink($file, $path)) {
            throw new RuntimeException(sprintf('Unable to create hook "%s"', $name, $path));
        }
    }

    /**
     * Set a hook in repository.
     *
     * @param string $name    Name of the hook
     * @param string $content Content of the hook
     *
     * @throws LogicException The hook is already defined
     */
    public function set($name, $content)
    {
        if ($this->has($name)) {
            throw new LogicException(sprintf('A hook "%s" is already defined', $name));
        }

        $path = $this->getPath($name);
        file_put_contents($path, $content);
        chmod($path, 0777);
    }

    /**
     * Removes a hook from repository.
     *
     * @param string $name Name of the hook
     *
     * @throws LogicException The hook is not present
     */
    public function remove($name)
    {
        if (!$this->has($name)) {
            throw new LogicException(sprintf('The hook "%s" was not found', $name));
        }

        unlink($this->getPath($name));
    }

    protected function getPath($name)
    {
        return $this->repository->getGitDir().'/hooks/'.$name;
    }
}


<?php

/**
 * This file is part of Gitonomy.
 *
 * (c) Alexandre Salomé <alexandre.salome@gmail.com>
 * (c) Julien DIDIER <genzo.wm@gmail.com>
 *
 * This source file is subject to the MIT license that is bundled
 * with this source code in the file LICENSE.
 */
namespace Gitonomy\Git;

use Gitonomy\Git\Exception\ProcessException;
use Gitonomy\Git\Exception\ReferenceNotFoundException;
use Gitonomy\Git\Util\StringHelper;

/**
 * @author Alexandre Salomé <alexandre.salome@gmail.com>
 */
class Log implements \Countable, \IteratorAggregate
{
    /**
     * @var Repository
     */
    protected $repository;

    /**
     * @var null|RevisionList
     */
    protected $revisions;

    /**
     * @var array
     */
    protected $paths;

    /**
     * @var int
     */
    protected $offset;

    /**
     * @var int
     */
    protected $limit;

    /**
     * Instanciates a git log object.
     *
     * @param Repository                       $repository the repository where log occurs
     * @param RevisionList|Revision|array|null $revisions  a list of revisions or null if you want all history
     * @param array                            $paths      paths to filter on
     * @param int|null                         $offset     start list from a given position
     * @param int|null                         $limit      limit number of fetched elements
     */
    public function __construct(Repository $repository, $revisions = null, $paths = null, $offset = null, $limit = null)
    {
        if (null !== $revisions && !$revisions instanceof RevisionList) {
            $revisions = new RevisionList($repository, $revisions);
        }

        if (null === $paths) {
            $paths = array();
        } elseif (is_string($paths)) {
            $paths = array($paths);
        } elseif (!is_array($paths)) {
            throw new \InvalidArgumentException(sprintf('Expected a string or an array, got a "%s".', is_object($paths) ? get_class($paths) : gettype($paths)));
        }

        $this->repository = $repository;
        $this->revisions = $revisions;
        $this->paths = $paths;
        $this->offset = $offset;
        $this->limit = $limit;
    }

    /**
     * @return Diff
     */
    public function getDiff()
    {
        return $this->repository->getDiff($this->revisions);
    }

    /**
     * @return RevisionList
     */
    public function getRevisions()
    {
        return $this->revisions;
    }

    /**
     * @return array
     */
    public function getPaths()
    {
        return $this->paths;
    }

    /**
     * @return int
     */
    public function getOffset()
    {
        return $this->offset;
    }

    /**
     * @param int $offset
     */
    public function setOffset($offset)
    {
        $this->offset = $offset;

        return $this;
    }

    /**
     * @return int
     */
    public function getLimit()
    {
        return $this->limit;
    }

    /**
     * @param int $limit
     */
    public function setLimit($limit)
    {
        $this->limit = $limit;

        return $this;
    }

    public function getSingleCommit()
    {
        $limit = $this->limit;
        $this->limit = 1;
        $commits = $this->getCommits();
        $this->setLimit($limit);

        if (count($commits) === 0) {
            throw new ReferenceNotFoundException('The log is empty');
        }

        return array_pop($commits);
    }

    /**
     * @return array
     */
    public function getCommits()
    {
        $args = array('--encoding='.StringHelper::getEncoding(), '--format=raw');

        if (null !== $this->offset) {
            $args[] = '--skip='.((int) $this->offset);
        }

        if (null !== $this->limit) {
            $args[] = '-n';
            $args[] = (int) $this->limit;
        }

        if (null !== $this->revisions) {
            $args = array_merge($args, $this->revisions->getAsTextArray());
        } else {
            $args[] = '--all';
        }

        $args[] = '--';

        $args = array_merge($args, $this->paths);

        try {
            $output = $this->repository->run('log', $args);
        } catch (ProcessException $e) {
            throw new ReferenceNotFoundException(sprintf('Can not find revision "%s"', implode(' ', $this->revisions->getAsTextArray())), null, $e);
        }

        $parser = new Parser\LogParser();
        $parser->parse($output);

        $result = array();
        foreach ($parser->log as $commitData) {
            $hash = $commitData['id'];
            unset($commitData['id']);

            $commit = $this->repository->getCommit($hash);
            $commit->setData($commitData);

            $result[] = $commit;
        }

        return $result;
    }

    /**
     * @see Countable
     */
    public function count()
    {
        return $this->countCommits();
    }

    /**
     * @see IteratorAggregate
     */
    public function getIterator()
    {
        return new \ArrayIterator($this->getCommits());
    }

    /**
     * Count commits, without offset or limit.
     *
     * @return int
     */
    public function countCommits()
    {
        if (null !== $this->revisions && count($this->revisions)) {
            $output = $this->repository->run('rev-list', array_merge(array('--count'), $this->revisions->getAsTextArray(), array('--'), $this->paths));
        } else {
            $output = $this->repository->run('rev-list', array_merge(array('--count', '--all', '--'), $this->paths));
        }

        return (int) $output;
    }
}


<?php

/**
 * This file is part of Gitonomy.
 *
 * (c) Alexandre Salomé <alexandre.salome@gmail.com>
 * (c) Julien DIDIER <genzo.wm@gmail.com>
 *
 * This source file is subject to the MIT license that is bundled
 * with this source code in the file LICENSE.
 */
namespace Gitonomy\Git;

use Gitonomy\Git\Diff\Diff;
use Gitonomy\Git\Exception\InvalidArgumentException;
use Gitonomy\Git\Exception\ProcessException;
use Gitonomy\Git\Exception\RuntimeException;
use Psr\Log\LoggerInterface;
use Symfony\Component\Process\Process;
use Symfony\Component\Process\ProcessUtils;

/**
 * Git repository object.
 *
 * Main entry point for browsing a Git repository.
 *
 * @author Alexandre Salomé <alexandre.salome@gmail.com>
 */
class Repository
{
    const DEFAULT_DESCRIPTION = "Unnamed repository; edit this file 'description' to name the repository.\n";

    /**
     * Directory containing git files.
     *
     * @var string
     */
    protected $gitDir;

    /**
     * Working directory.
     *
     * @var string
     */
    protected $workingDir;

    /**
     * Cache containing all objects of the repository.
     *
     * Associative array, indexed by object hash
     *
     * @var array
     */
    protected $objects;

    /**
     * Reference bag associated to this repository.
     *
     * @var ReferenceBag
     */
    protected $referenceBag;

    /**
     * Logger (can be null).
     *
     * @var LoggerInterface
     */
    protected $logger;

    /**
     * Path to git command.
     */
    protected $command;

    /**
     * Debug flag, indicating if errors should be thrown.
     *
     * @var bool
     */
    protected $debug;

    /**
     * Environment variables that should be set for every running process.
     *
     * @var array
     */
    protected $environmentVariables;

    /**
     * Timeout that should be set for every running process.
     *
     * @var int
     */
    protected $processTimeout;

    /**
     * Constructs a new repository.
     *
     * Available options are:
     *
     * * working_dir : specify where working copy is located (option --work-tree)
     *
     * * debug : (default: true) enable/disable minimize errors and reduce log level
     *
     * * logger : a logger to use for logging actions (Psr\Log\LoggerInterface)
     *
     * * environment_variables : define environment variables for every ran process
     *
     * @param string $dir     path to git repository
     * @param array  $options array of options values
     *
     * @throws InvalidArgumentException The folder does not exists
     */
    public function __construct($dir, $options = array())
    {
        $is_windows = defined('PHP_WINDOWS_VERSION_BUILD');
        $options = array_merge(array(
            'working_dir' => null,
            'debug' => true,
            'logger' => null,
            'environment_variables' => $is_windows ? array('PATH' => getenv('path')) : array(),
            'command' => 'git',
            'process_timeout' => 3600,
        ), $options);

        if (null !== $options['logger'] && !$options['logger'] instanceof LoggerInterface) {
            throw new InvalidArgumentException(sprintf('Argument "logger" passed to Repository should be a Psr\Log\LoggerInterface. A %s was provided', is_object($options['logger']) ? get_class($options['logger']) : gettype($options['logger'])));
        }

        $this->logger = $options['logger'];
        $this->initDir($dir, $options['working_dir']);

        $this->objects = array();
        $this->debug = (bool) $options['debug'];
        $this->environmentVariables = $options['environment_variables'];
        $this->processTimeout = $options['process_timeout'];
        $this->command = $options['command'];

        if (true === $this->debug && null !== $this->logger) {
            $this->logger->debug(sprintf('Repository created (git dir: "%s", working dir: "%s")', $this->gitDir, $this->workingDir ?: 'none'));
        }
    }

    /**
     * Initializes directory attributes on repository:.
     *
     * @param string $gitDir     directory of a working copy with files checked out
     * @param string $workingDir directory containing git files (objects, config...)
     */
    private function initDir($gitDir, $workingDir = null)
    {
        $realGitDir = realpath($gitDir);

        if (false === $realGitDir) {
            throw new InvalidArgumentException(sprintf('Directory "%s" does not exist or is not a directory', $gitDir));
        } elseif (!is_dir($realGitDir)) {
            throw new InvalidArgumentException(sprintf('Directory "%s" does not exist or is not a directory', $realGitDir));
        } elseif (null === $workingDir && is_dir($realGitDir.'/.git')) {
            $workingDir = $realGitDir;
            $realGitDir = $realGitDir.'/.git';
        }

        $this->gitDir = $realGitDir;
        $this->workingDir = $workingDir;
    }

    /**
     * Tests if repository is a bare repository.
     *
     * @return bool
     */
    public function isBare()
    {
        return null === $this->workingDir;
    }

    /**
     * Returns the HEAD resolved as a commit.
     *
     * @return Commit|null returns a Commit or ``null`` if repository is empty
     */
    public function getHeadCommit()
    {
        $head = $this->getHead();

        if ($head instanceof Reference) {
            return $head->getCommit();
        }

        return $head;
    }

    /**
     * @throws RuntimeException Unable to find file HEAD (debug-mode only)
     *
     * @return Reference|Commit|null current HEAD object or null if error occurs
     */
    public function getHead()
    {
        $file = $this->gitDir.'/HEAD';

        if (!file_exists($file)) {
            $message = sprintf('Unable to find HEAD file ("%s")', $file);

            if (null !== $this->logger) {
                $this->logger->error($message);
            }

            if (true === $this->debug) {
                throw new RuntimeException($message);
            }
        }

        $content = trim(file_get_contents($file));

        if (null !== $this->logger) {
            $this->logger->debug('HEAD file read: '.$content);
        }

        if (preg_match('/^ref: (.+)$/', $content, $vars)) {
            return $this->getReferences()->get($vars[1]);
        } elseif (preg_match('/^[0-9a-f]{40}$/', $content)) {
            return $this->getCommit($content);
        }

        $message = sprintf('Unexpected HEAD file content (file: %s). Content of file: %s', $file, $content);

        if (null !== $this->logger) {
            $this->logger->error($message);
        }

        if (true === $this->debug) {
            throw new RuntimeException($message);
        }
    }

    /**
     * @return bool
     */
    public function isHeadDetached()
    {
        return !$this->isHeadAttached();
    }

    /**
     * @return bool
     */
    public function isHeadAttached()
    {
        return $this->getHead() instanceof Reference;
    }

    /**
     * Returns the path to the git repository.
     *
     * @return string A directory path
     */
    public function getPath()
    {
        return $this->workingDir === null ? $this->gitDir : $this->workingDir;
    }

    /**
     * Returns the directory containing git files (git-dir).
     *
     * @return string
     */
    public function getGitDir()
    {
        return $this->gitDir;
    }

    /**
     * Returns the work-tree directory. This may be null if repository is
     * bare.
     *
     * @return string path to repository or null if repository is bare
     */
    public function getWorkingDir()
    {
        return $this->workingDir;
    }

    /**
     * Instanciates a revision.
     *
     * @param string $name Name of the revision
     *
     * @return Revision
     */
    public function getRevision($name)
    {
        return new Revision($this, $name);
    }

    /**
     * @return WorkingCopy
     */
    public function getWorkingCopy()
    {
        return new WorkingCopy($this);
    }

    /**
     * Returns the reference list associated to the repository.
     *
     * @return ReferenceBag
     */
    public function getReferences()
    {
        if (null === $this->referenceBag) {
            $this->referenceBag = new ReferenceBag($this);
        }

        return $this->referenceBag;
    }

    /**
     * Instanciates a commit object or fetches one from the cache.
     *
     * @param string $hash A commit hash, with a length of 40
     *
     * @return Commit
     */
    public function getCommit($hash)
    {
        if (!isset($this->objects[$hash])) {
            $this->objects[$hash] = new Commit($this, $hash);
        }

        return $this->objects[$hash];
    }

    /**
     * Instanciates a tree object or fetches one from the cache.
     *
     * @param string $hash A tree hash, with a length of 40
     *
     * @return Tree
     */
    public function getTree($hash)
    {
        if (!isset($this->objects[$hash])) {
            $this->objects[$hash] = new Tree($this, $hash);
        }

        return $this->objects[$hash];
    }

    /**
     * Instanciates a blob object or fetches one from the cache.
     *
     * @param string $hash A blob hash, with a length of 40
     *
     * @return Blob
     */
    public function getBlob($hash)
    {
        if (!isset($this->objects[$hash])) {
            $this->objects[$hash] = new Blob($this, $hash);
        }

        return $this->objects[$hash];
    }

    public function getBlame($revision, $file, $lineRange = null)
    {
        if (is_string($revision)) {
            $revision = $this->getRevision($revision);
        }

        return new Blame($this, $revision, $file, $lineRange);
    }

    /**
     * Returns log for a given set of revisions and paths.
     *
     * All those values can be null, meaning everything.
     *
     * @param array $revisions An array of revisions to show logs from. Can be
     *                         any text value type
     * @param array $paths     Restrict log to modifications occurring on given
     *                         paths.
     * @param int   $offset    Start from a given offset in results.
     * @param int   $limit     Limit number of total results.
     *
     * @return Log
     */
    public function getLog($revisions = null, $paths = null, $offset = null, $limit = null)
    {
        return new Log($this, $revisions, $paths, $offset, $limit);
    }

    /**
     * @return Diff
     */
    public function getDiff($revisions)
    {
        if (null !== $revisions && !$revisions instanceof RevisionList) {
            $revisions = new RevisionList($this, $revisions);
        }

        $args = array_merge(array('-r', '-p', '-m', '-M', '--no-commit-id', '--full-index'), $revisions->getAsTextArray());

        $diff = Diff::parse($this->run('diff', $args));
        $diff->setRepository($this);

        return $diff;
    }

    /**
     * Returns the size of repository, in kilobytes.
     *
     * @return int A sum, in kilobytes
     *
     * @throws RuntimeException An error occurred while computing size
     */
    public function getSize()
    {
        $commandlineArguments = array('du', '-skc', $this->gitDir);
        $commandline = $this->normalizeCommandlineArguments($commandlineArguments);
        $process = new Process($commandline);
        $process->run();

        if (!preg_match('/(\d+)\s+total$/', trim($process->getOutput()), $vars)) {
            $message = sprintf("Unable to parse process output\ncommand: %s\noutput: %s", $process->getCommandLine(), $process->getOutput());

            if (null !== $this->logger) {
                $this->logger->error($message);
            }

            if (true === $this->debug) {
                throw new RuntimeException('unable to parse repository size output');
            }

            return;
        }

        return $vars[1];
    }

    /**
     * Executes a shell command on the repository, using PHP pipes.
     *
     * @param string $command The command to execute
     */
    public function shell($command, array $env = array())
    {
        $argument = sprintf('%s \'%s\'', $command, $this->gitDir);

        $prefix = '';
        foreach ($env as $name => $value) {
            $prefix .= sprintf('export %s=%s;', escapeshellarg($name), escapeshellarg($value));
        }

        proc_open($prefix.'git shell -c '.escapeshellarg($argument), array(STDIN, STDOUT, STDERR), $pipes);
    }

    /**
     * Returns the hooks object.
     *
     * @return Hooks
     */
    public function getHooks()
    {
        return new Hooks($this);
    }

    /**
     * Returns description of repository from description file in git directory.
     *
     * @return string The description
     */
    public function getDescription()
    {
        $file = $this->gitDir.'/description';
        $exists = is_file($file);

        if (null !== $this->logger && true === $this->debug) {
            if (false === $exists) {
                $this->logger->debug(sprintf('no description file in repository ("%s")', $file));
            } else {
                $this->logger->debug(sprintf('reading description file in repository ("%s")', $file));
            }
        }

        if (false === $exists) {
            return static::DEFAULT_DESCRIPTION;
        }

        return file_get_contents($this->gitDir.'/description');
    }

    /**
     * Tests if repository has a custom set description.
     *
     * @return bool
     */
    public function hasDescription()
    {
        return static::DEFAULT_DESCRIPTION !== $this->getDescription();
    }

    /**
     * Changes the repository description (file description in git-directory).
     *
     * @return Repository the current repository
     */
    public function setDescription($description)
    {
        $file = $this->gitDir.'/description';

        if (null !== $this->logger && true === $this->debug) {
            $this->logger->debug(sprintf('change description file content to "%s" (file: %s)', $description, $file));
        }
        file_put_contents($file, $description);

        return $this;
    }

    /**
     * This command is a facility command. You can run any command
     * directly on git repository.
     *
     * @param string $command Git command to run (checkout, branch, tag)
     * @param array  $args    Arguments of git command
     *
     * @return string Output of a successful process or null if execution failed and debug-mode is disabled.
     *
     * @throws RuntimeException Error while executing git command (debug-mode only)
     */
    public function run($command, $args = array())
    {
        $process = $this->getProcess($command, $args);

        if ($this->logger) {
            $this->logger->info(sprintf('run command: %s "%s" ', $command, implode(' ', $args)));
            $before = microtime(true);
        }

        $process->run();

        $output = $process->getOutput();

        if ($this->logger && $this->debug) {
            $duration = microtime(true) - $before;
            $this->logger->debug(sprintf('last command (%s) duration: %sms', $command, sprintf('%.2f', $duration * 1000)));
            $this->logger->debug(sprintf('last command (%s) return code: %s', $command, $process->getExitCode()));
            $this->logger->debug(sprintf('last command (%s) output: %s', $command, $output));
        }

        if (!$process->isSuccessful()) {
            $error = sprintf("error while running %s\n output: \"%s\"", $command, $process->getErrorOutput());

            if ($this->logger) {
                $this->logger->error($error);
            }

            if ($this->debug) {
                throw new ProcessException($process);
            }

            return;
        }

        return $output;
    }

    /**
     * Set repository logger.
     *
     * @param LoggerInterface $logger A logger
     *
     * @return Repository The current repository
     */
    public function setLogger(LoggerInterface $logger)
    {
        $this->logger = $logger;

        return $this;
    }

    /**
     * Returns repository logger.
     *
     * @return LoggerInterface the logger or null
     */
    public function getLogger()
    {
        return $this->logger;
    }

    /**
     * Clones the current repository to a new directory and return instance of new repository.
     *
     * @param string $path path to the new repository in which current repository will be cloned
     * @param bool   $bare flag indicating if repository is bare or has a working-copy
     *
     * @return Repository the newly created repository
     */
    public function cloneTo($path, $bare = true, array $options = array())
    {
        return Admin::cloneTo($path, $this->gitDir, $bare, $options);
    }

    /**
     * This internal method is used to create a process object.
     *
     * Made private to be sure that process creation is handled through the run method.
     * run method ensures logging and debug.
     *
     * @see self::run
     */
    private function getProcess($command, $args = array())
    {
        $base = array($this->command, '--git-dir', $this->gitDir);

        if ($this->workingDir) {
            $base = array_merge($base, array('--work-tree', $this->workingDir));
        }

        $base[] = $command;

        $commandlineArguments = array_merge($base, $args);
        $commandline = $this->normalizeCommandlineArguments($commandlineArguments);

        $process = new Process($commandline);
        $process->setEnv($this->environmentVariables);
        $process->setTimeout($this->processTimeout);
        $process->setIdleTimeout($this->processTimeout);

        return $process;
    }

    /**
     * This internal helper method is used to convert an array of commandline
     * arguments to an escaped commandline string for older versions of the
     * Symfony Process component.
     *
     * It acts as a backward compatible layer for Symfony Process < 3.3.
     *
     * @param array $arguments a list of command line arguments
     *
     * @return string|array a single escaped string (< 4.0) or a raw array of
     *                      the arguments passed in (4.0+)
     *
     * @see Process::escapeArgument()
     * @see ProcessUtils::escapeArgument()
     */
    private function normalizeCommandlineArguments(array $arguments)
    {
        // From version 4.0 and onwards, the Process accepts an array of
        // arguments, and escaping is taken care of automatically.
        if (!class_exists('Symfony\Component\Process\ProcessBuilder')) {
            return $arguments;
        }

        // For version < 3.3, the Process only accepts a simple string
        // as commandline, and escaping has to happen manually.
        $commandline = implode(' ', array_map(
            'Symfony\Component\Process\ProcessUtils::escapeArgument',
            $arguments
        ));

        return $commandline;
    }
}


<?php

/**
 * This file is part of Gitonomy.
 *
 * (c) Alexandre Salomé <alexandre.salome@gmail.com>
 * (c) Julien DIDIER <genzo.wm@gmail.com>
 *
 * This source file is subject to the MIT license that is bundled
 * with this source code in the file LICENSE.
 */
namespace Gitonomy\Git;

use Gitonomy\Git\Diff\Diff;
use Gitonomy\Git\Exception\InvalidArgumentException;
use Gitonomy\Git\Exception\ProcessException;
use Gitonomy\Git\Exception\ReferenceNotFoundException;
use Gitonomy\Git\Util\StringHelper;

/**
 * Representation of a Git commit.
 *
 * @author Alexandre Salomé <alexandre.salome@gmail.com>
 */
class Commit extends Revision
{
    /**
     * Associative array of commit data.
     *
     * @var array
     */
    private $data = array();

    /**
     * Constructor.
     *
     * @param Gitonomy\Git\Repository $repository Repository of the commit
     * @param string                  $hash       Hash of the commit
     */
    public function __construct(Repository $repository, $hash, array $data = array())
    {
        if (!preg_match('/^[a-f0-9]{40}$/', $hash)) {
            throw new ReferenceNotFoundException($hash);
        }

        parent::__construct($repository, $hash);

        $this->setData($data);
    }

    public function setData(array $data)
    {
        foreach ($data as $name => $value) {
            $this->data[$name] = $value;
        }
    }

    /**
     * @return Diff
     */
    public function getDiff()
    {
        $args = array('-r', '-p', '-m', '-M', '--no-commit-id', '--full-index', $this->revision);

        $diff = Diff::parse($this->repository->run('diff-tree', $args));
        $diff->setRepository($this->repository);

        return $diff;
    }

    /**
     * Returns the commit hash.
     *
     * @return string A SHA1 hash
     */
    public function getHash()
    {
        return $this->revision;
    }

    /**
     * Returns the short commit hash.
     *
     * @return string A SHA1 hash
     */
    public function getShortHash()
    {
        return $this->getData('shortHash');
    }

    /**
     * Returns a fixed-with short hash.
     */
    public function getFixedShortHash($length = 6)
    {
        return StringHelper::substr($this->revision, 0, $length);
    }

    /**
     * Returns parent hashes.
     *
     * @return array An array of SHA1 hashes
     */
    public function getParentHashes()
    {
        return $this->getData('parentHashes');
    }

    /**
     * Returns the parent commits.
     *
     * @return array An array of Commit objects
     */
    public function getParents()
    {
        $result = array();
        foreach ($this->getData('parentHashes') as $parentHash) {
            $result[] = $this->repository->getCommit($parentHash);
        }

        return $result;
    }

    /**
     * Returns the tree hash.
     *
     * @return string A SHA1 hash
     */
    public function getTreeHash()
    {
        return $this->getData('treeHash');
    }

    public function getTree()
    {
        return $this->getData('tree');
    }

    /**
     * @return Commit
     */
    public function getLastModification($path = null)
    {
        if (0 === strpos($path, '/')) {
            $path = StringHelper::substr($path, 1);
        }

        if ($getWorkingDir = $this->repository->getWorkingDir()) {
            $path = $getWorkingDir.'/'.$path;
        }

        $result = $this->repository->run('log', array('--format=%H', '-n', 1, $this->revision, '--', $path));

        return $this->repository->getCommit(trim($result));
    }

    /**
     * Returns the first line of the commit, and the first 50 characters.
     *
     * Ported from https://github.com/fabpot/Twig-extensions/blob/d67bc7e69788795d7905b52d31188bbc1d390e01/lib/Twig/Extensions/Extension/Text.php#L52-L109
     *
     * @param int    $length
     * @param bool   $preserve
     * @param string $separator
     *
     * @return string
     */
    public function getShortMessage($length = 50, $preserve = false, $separator = '...')
    {
        $message = $this->getData('subjectMessage');

        if (StringHelper::strlen($message) > $length) {
            if ($preserve && false !== ($breakpoint = StringHelper::strpos($message, ' ', $length))) {
                $length = $breakpoint;
            }

            return rtrim(StringHelper::substr($message, 0, $length)).$separator;
        }

        return $message;
    }

    /**
     * Resolves all references associated to this commit.
     *
     * @return array An array of references (Branch, Tag, Squash)
     */
    public function resolveReferences()
    {
        return $this->repository->getReferences()->resolve($this);
    }

    /**
     * Find branch containing the commit.
     *
     * @param bool $local  set true to try to locate a commit on local repository
     * @param bool $remote set true to try to locate a commit on remote repository
     *
     * @return array An array of Reference\Branch
     */
    public function getIncludingBranches($local = true, $remote = true)
    {
        $arguments = array('--contains', $this->revision);

        if ($local && $remote) {
            $arguments[] = '-a';
        } elseif (!$local && $remote) {
            $arguments[] = '-r';
        } elseif (!$local && !$remote) {
            throw new InvalidArgumentException('You should a least set one argument to true');
        }

        try {
            $result = $this->repository->run('branch', $arguments);
        } catch (ProcessException $e) {
            return array();
        }

        if (!$result) {
            return array();
        }

        $branchesName = explode("\n", trim(str_replace('*', '', $result)));
        $branchesName = array_filter($branchesName, function ($v) {
            return false === StringHelper::strpos($v, '->');
        });
        $branchesName = array_map('trim', $branchesName);

        $references = $this->repository->getReferences();

        $branches = array();
        foreach ($branchesName as $branchName) {
            if (false === $local) {
                $branches[] = $references->getRemoteBranch($branchName);
            } elseif (0 === StringHelper::strrpos($branchName, 'remotes/')) {
                $branches[] = $references->getRemoteBranch(str_replace('remotes/', '', $branchName));
            } else {
                $branches[] = $references->getBranch($branchName);
            }
        }

        return $branches;
    }

    /**
     * Returns the author name.
     *
     * @return string A name
     */
    public function getAuthorName()
    {
        return $this->getData('authorName');
    }

    /**
     * Returns the author email.
     *
     * @return string An email
     */
    public function getAuthorEmail()
    {
        return $this->getData('authorEmail');
    }

    /**
     * Returns the authoring date.
     *
     * @return \DateTime A time object
     */
    public function getAuthorDate()
    {
        return $this->getData('authorDate');
    }

    /**
     * Returns the committer name.
     *
     * @return string A name
     */
    public function getCommitterName()
    {
        return $this->getData('committerName');
    }

    /**
     * Returns the comitter email.
     *
     * @return string An email
     */
    public function getCommitterEmail()
    {
        return $this->getData('committerEmail');
    }

    /**
     * Returns the authoring date.
     *
     * @return \DateTime A time object
     */
    public function getCommitterDate()
    {
        return $this->getData('committerDate');
    }

    /**
     * Returns the message of the commit.
     *
     * @return string A commit message
     */
    public function getMessage()
    {
        return $this->getData('message');
    }

    /**
     * Returns the subject message (the first line).
     *
     * @return string The subject message
     */
    public function getSubjectMessage()
    {
        return $this->getData('subjectMessage');
    }

    /**
     * Return the body message.
     *
     * @return string The body message
     */
    public function getBodyMessage()
    {
        return $this->getData('bodyMessage');
    }

    /**
     * @inheritdoc
     */
    public function getCommit()
    {
        return $this;
    }

    private function getData($name)
    {
        if (isset($this->data[$name])) {
            return $this->data[$name];
        }

        if ($name === 'shortHash') {
            $this->data['shortHash'] = trim($this->repository->run('log', array('--abbrev-commit', '--format=%h', '-n', 1, $this->revision)));

            return $this->data['shortHash'];
        }

        if ($name === 'tree') {
            $this->data['tree'] = $this->repository->getTree($this->getData('treeHash'));

            return $this->data['tree'];
        }

        if ($name === 'subjectMessage') {
            $lines = explode("\n", $this->getData('message'));
            $this->data['subjectMessage'] = reset($lines);

            return $this->data['subjectMessage'];
        }

        if ($name === 'bodyMessage') {
            $message = $this->getData('message');

            $lines = explode("\n", $message);

            array_shift($lines);
            array_shift($lines);

            $data['bodyMessage'] = implode("\n", $lines);

            return $data['bodyMessage'];
        }

        $parser = new Parser\CommitParser();
        try {
            $result = $this->repository->run('cat-file', array('commit', $this->revision));
        } catch (ProcessException $e) {
            throw new ReferenceNotFoundException(sprintf('Can not find reference "%s"', $this->revision));
        }

        $parser->parse($result);

        $this->data['treeHash'] = $parser->tree;
        $this->data['parentHashes'] = $parser->parents;
        $this->data['authorName'] = $parser->authorName;
        $this->data['authorEmail'] = $parser->authorEmail;
        $this->data['authorDate'] = $parser->authorDate;
        $this->data['committerName'] = $parser->committerName;
        $this->data['committerEmail'] = $parser->committerEmail;
        $this->data['committerDate'] = $parser->committerDate;
        $this->data['message'] = $parser->message;

        if (!isset($this->data[$name])) {
            throw new \InvalidArgumentException(sprintf('No data named "%s" in Commit.', $name));
        }

        return $this->data[$name];
    }
}


<?php

/**
 * This file is part of Gitonomy.
 *
 * (c) Alexandre Salomé <alexandre.salome@gmail.com>
 * (c) Julien DIDIER <genzo.wm@gmail.com>
 *
 * This source file is subject to the MIT license that is bundled
 * with this source code in the file LICENSE.
 */
namespace Gitonomy\Git;

use Gitonomy\Git\Exception\RuntimeException;
use Symfony\Component\Process\Process;

/**
 * Administration class for Git repositories.
 *
 * @author Alexandre Salomé <alexandre.salome@gmail.com>
 */
class Admin
{
    /**
     * Initializes a repository and returns the instance.
     *
     * @param string $path    path to the repository
     * @param bool   $bare    indicate to create a bare repository
     * @param array  $options options for Repository creation
     *
     * @return Repository
     *
     * @throws RuntimeException Directory exists or not writable (only if debug=true)
     */
    public static function init($path, $bare = true, array $options = array())
    {
        $process = static::getProcess('init', array_merge(array('-q'), $bare ? array('--bare') : array(), array($path)), $options);

        $process->run();

        if (!$process->isSuccessFul()) {
            throw new RuntimeException(sprintf("Error on repository initialization, command wasn't successful (%s). Error output:\n%s", $process->getCommandLine(), $process->getErrorOutput()));
        }

        return new Repository($path, $options);
    }

    /**
     * Checks the validity of a git repository url without cloning it.
     *
     * This will use the `ls-remote` command of git against the given url.
     * Usually, this command returns 0 when successful, and 128 when the
     * repository is not found.
     *
     * @param string $url     url of repository to check
     * @param array  $options options for Repository creation
     *
     * @return bool true if url is valid
     */
    public static function isValidRepository($url, array $options = array())
    {
        $process = static::getProcess('ls-remote', array($url), $options);

        $process->run();

        return $process->isSuccessFul();
    }

    /**
     * Clone a repository to a local path.
     *
     * @param string $path    indicates where to clone repository
     * @param string $url     url of repository to clone
     * @param bool   $bare    indicates if repository should be bare or have a working copy
     * @param array  $options options for Repository creation
     *
     * @return Repository
     */
    public static function cloneTo($path, $url, $bare = true, array $options = array())
    {
        $args = $bare ? array('--bare') : array();

        return static::cloneRepository($path, $url, $args, $options);
    }

    /**
     * Clone a repository branch to a local path.
     *
     * @param string $path    indicates where to clone repository
     * @param string $url     url of repository to clone
     * @param string $branch  branch to clone
     * @param bool   $bare    indicates if repository should be bare or have a working copy
     * @param array  $options options for Repository creation
     *
     * @return Repository
     */
    public static function cloneBranchTo($path, $url, $branch, $bare = true, $options = array())
    {
        $args = array('--branch', $branch);
        if ($bare) {
            $args[] = '--bare';
        }

        return static::cloneRepository($path, $url, $args, $options);
    }

    /**
     * Mirrors a repository (fetch all revisions, not only branches).
     *
     * @param string $path    indicates where to clone repository
     * @param string $url     url of repository to clone
     * @param array  $options options for Repository creation
     *
     * @return Repository
     */
    public static function mirrorTo($path, $url, array $options = array())
    {
        return static::cloneRepository($path, $url, array('--mirror'), $options);
    }

    /**
     * Internal method to launch effective ``git clone`` command.
     *
     * @param string $path    indicates where to clone repository
     * @param string $url     url of repository to clone
     * @param array  $args    arguments to be added to the command-line
     * @param array  $options options for Repository creation
     *
     * @return Repository
     */
    public static function cloneRepository($path, $url, array $args = array(), array $options = array())
    {
        $process = static::getProcess('clone', array_merge(array('-q'), $args, array($url, $path)), $options);

        $process->run();

        if (!$process->isSuccessFul()) {
            throw new RuntimeException(sprintf('Error while initializing repository: %s', $process->getErrorOutput()));
        }

        return new Repository($path, $options);
    }

    /**
     * This internal method is used to create a process object.
     */
    private static function getProcess($command, array $args = array(), array $options = array())
    {
        $is_windows = defined('PHP_WINDOWS_VERSION_BUILD');
        $options = array_merge(array(
            'environment_variables' => $is_windows ? array('PATH' => getenv('PATH')) : array(),
            'command' => 'git',
            'process_timeout' => 3600,
        ), $options);

        $commandline = array_merge(array($options['command'], $command), $args);

        // Backward compatible layer for Symfony Process < 4.0.
        if (class_exists('Symfony\Component\Process\ProcessBuilder')) {
            $commandline = implode(' ', array_map(
                'Symfony\Component\Process\ProcessUtils::escapeArgument',
                $commandline
            ));
        }

        $process = new Process($commandline);
        $process->setEnv($options['environment_variables']);
        $process->setTimeout($options['process_timeout']);
        $process->setIdleTimeout($options['process_timeout']);

        return $process;
    }
}