├── LICENSE ├── README.md ├── bin └── git-ssh-wrapper.sh ├── composer.json └── src ├── Contract └── OutputEventSubscriberInterface.php ├── Event ├── AbstractGitEvent.php ├── GitBypassEvent.php ├── GitErrorEvent.php ├── GitOutputEvent.php ├── GitPrepareEvent.php └── GitSuccessEvent.php ├── EventSubscriber ├── AbstractOutputEventSubscriber.php ├── GitLoggerEventSubscriber.php └── StreamOutputEventSubscriber.php ├── Exception └── GitException.php ├── GitBranches.php ├── GitCommand.php ├── GitTags.php ├── GitWorkingCopy.php ├── GitWrapper.php ├── Process └── GitProcess.php ├── Strings └── GitStrings.php └── ValueObject └── CommandName.php /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Acquia, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PHP Wrapper around GIT 2 | 3 | [![Total Downloads](https://img.shields.io/packagist/dt/cpliakas/git-wrapper.svg?style=flat-square)](https://packagist.org/packages/cpliakas/git-wrapper) 4 | [![Latest Stable Version](https://img.shields.io/packagist/v/cpliakas/git-wrapper.svg?style=flat-square)](https://packagist.org/packages/cpliakas/git-wrapper) 5 | 6 | Git Wrapper provides a **readable API that abstracts challenges of executing Git commands from within a PHP process** for you. 7 | 8 | - It's built upon the [`Symfony\Process`](https://symfony.com/doc/current/components/process.html) to execute the Git command with **cross-platform support** and uses the best-in-breed techniques available to PHP. 9 | - This library also provides an SSH wrapper script and API method for developers to **easily specify a private key other than default** by using [the technique from StackOverflow](http://stackoverflow.com/a/3500308/870667). 10 | - Finally, various commands are expected to be executed in the directory containing the working copy. **The library handles this transparently** so the developer doesn't have to think about it. 11 | 12 | ## Install 13 | 14 | ```bash 15 | composer require cpliakas/git-wrapper 16 | ``` 17 | 18 | ## Usage 19 | 20 | ```php 21 | use GitWrapper\GitWrapper; 22 | 23 | // Initialize the library. If the path to the Git binary is not passed as 24 | // the first argument when instantiating GitWrapper, it is auto-discovered. 25 | require_once __DIR__ . '/vendor/autoload.php'; 26 | 27 | $gitWrapper = new GitWrapper(); 28 | 29 | // Optionally specify a private key other than one of the defaults 30 | $gitWrapper->setPrivateKey('/path/to/private/key'); 31 | 32 | // Clone a repo into `/path/to/working/copy`, get a working copy object 33 | $git = $gitWrapper->cloneRepository('git://github.com/cpliakas/git-wrapper.git', '/path/to/working/copy'); 34 | 35 | // Create a file in the working copy 36 | touch('/path/to/working/copy/text.txt'); 37 | 38 | // Add it, commit it, and push the change 39 | $git->add('test.txt'); 40 | $git->commit('Added the test.txt file as per the examples.'); 41 | $git->push(); 42 | 43 | // Render the output for operation 44 | echo $git->push(); 45 | 46 | // Stream output of subsequent Git commands in real time to STDOUT and STDERR. 47 | $gitWrapper->streamOutput(); 48 | 49 | // Execute an arbitrary git command. 50 | // The following is synonymous with `git config -l` 51 | $gitWrapper->git('config -l'); 52 | ``` 53 | 54 | All command methods adhere to the following paradigm: 55 | 56 | ```php 57 | $git->command($arg1, $arg2, ..., $options); 58 | ``` 59 | 60 | Replace `command` with the Git command being executed, e.g. `checkout`, `push`, 61 | etc. The `$arg*` parameters are a variable number of arguments as they would be 62 | passed to the Git command line tool. `$options` is an optional array of command 63 | line options in the following format: 64 | 65 | ```php 66 | $options = [ 67 | 'verbose' => true, // Passes the "--verbose" flag. 68 | 't' => 'my-branch', // Passes the "-t my-branch" option. 69 | ]; 70 | ``` 71 | 72 | #### Logging 73 | 74 | Use the logger listener with [PSR-3](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-3-logger-interface.md) compatible loggers such as [Monolog](https://github.com/Seldaek/monolog) to log commands that are executed. 75 | 76 | ```php 77 | pushHandler(new StreamHandler('git.log', Logger::DEBUG)); 86 | 87 | // Instantiate the subscriber, add the logger to it, and register it. 88 | $gitWrapper->addLoggerEventSubscriber(new GitLoggerEventSubscriber($logger)); 89 | 90 | $git = $gitWrapper->cloneRepository('git://github.com/cpliakas/git-wrapper.git', '/path/to/working/copy'); 91 | 92 | // The "git.log" file now has info about the command that was executed above. 93 | ``` 94 | 95 | ## Gotchas 96 | 97 | There are a few "gotchas" that are out of scope for this library to solve but might prevent a successful implementation of running Git via PHP. 98 | 99 | ### Missing HOME Environment Variable 100 | 101 | Sometimes the `HOME` environment variable is not set in the Git process that is spawned by PHP. This will cause many Git operations to fail. It is advisable to set the `HOME` environment variable to a path outside of the document root that the web server has write access to. Note that this environment variable is only set for the process running Git and NOT the PHP process that is spawns it. 102 | 103 | ```php 104 | $gitWrapper->setEnvVar('HOME', '/path/to/a/private/writable/dir'); 105 | ``` 106 | 107 | It is important that the storage is persistent as the `~/.gitconfig` file will be written to this location. See the following "gotcha" for why this is important. 108 | 109 | ### Missing Identity And Configurations 110 | 111 | Many repositories require that a name and email address are specified. This data is set by running `git config [name] [value]` on the command line, and the configurations are usually stored in the `~/.gitconfig file`. When executing Git via PHP, however, the process might have a different home directory than the user who normally runs git via the command line. Therefore no identity is sent to the repository, and it will likely throw an error. 112 | 113 | ```php 114 | // Set configuration options globally. 115 | $gitWrapper->git('config --global user.name "User name"'); 116 | $gitWrapper->git('config --global user.email user@example.com'); 117 | 118 | // Set configuration options per repository. 119 | $git->config('user.name', 'User name'); 120 | $git->config('user.email', 'user@example.com'); 121 | ``` 122 | 123 | ### Commits To Repositories With No Changes 124 | 125 | Running `git commit` on a repository *with no changes* fails with exception. To prevent that, check changes like: 126 | 127 | ```php 128 | if ($git->hasChanges()) { 129 | $git->commit('Committed the changes.'); 130 | } 131 | ``` 132 | 133 | ### Permissions Of The GIT_SSH Wrapper Script 134 | 135 | On checkout, the bin/git-ssh-wrapper.sh script should be executable. If it is not, git commands will fail if a non-default private key is specified. 136 | 137 | ```bash 138 | $ chmod +x ./bin/git-ssh-wrapper.sh 139 | ``` 140 | 141 | ### Timeout 142 | 143 | There is a default timeout of 60 seconds. This might cause "issues" when you use the clone feature of bigger projects or with slow internet. 144 | 145 | ```php 146 | $this->gitWrapper = new GitWrapper(); 147 | $this->gitWrapper->setTimeout(120); 148 | ``` 149 | -------------------------------------------------------------------------------- /bin/git-ssh-wrapper.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | ssh -i $GIT_SSH_KEY -p $GIT_SSH_PORT -o StrictHostKeyChecking=no -o IdentitiesOnly=yes "$@" 4 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cpliakas/git-wrapper", 3 | "description": "A PHP wrapper around the Git command line utility.", 4 | "keywords": ["git", "git wrapper", "cli"], 5 | "license": "MIT", 6 | "authors": [ 7 | { "name": "Chris Pliakas", "email": "opensource@chrispliakas.com" }, 8 | { "name": "Tomas Votruba", "email": "tomas.vot@gmail.com" } 9 | ], 10 | "require": { 11 | "php": ">=7.3", 12 | "symfony/process": "^4.4|^5.1", 13 | "symfony/event-dispatcher": "^4.4|^5.1", 14 | "nette/utils": "^3.0" 15 | }, 16 | "require-dev": { 17 | "phpunit/phpunit": "^9.5", 18 | "symfony/filesystem": "^4.4|^5.1", 19 | "psr/log": "^1.1", 20 | "symplify/easy-coding-standard": "^9.0", 21 | "phpstan/phpstan": "^0.12.64", 22 | "symplify/changelog-linker": "^9.0", 23 | "symplify/phpstan-extensions": "^9.0", 24 | "rector/rector": "^0.9", 25 | "tracy/tracy": "^2.7", 26 | "phpstan/phpstan-phpunit": "^0.12", 27 | "symplify/phpstan-rules": "^9.0", 28 | "ondram/ci-detector": "^3.5" 29 | }, 30 | "autoload": { 31 | "psr-4": { 32 | "GitWrapper\\": "src" 33 | } 34 | }, 35 | "autoload-dev": { 36 | "psr-4": { 37 | "GitWrapper\\Tests\\": "tests" 38 | } 39 | }, 40 | "suggest": { 41 | "monolog/monolog": "Enables logging of executed git commands" 42 | }, 43 | "scripts": { 44 | "check-cs": "vendor/bin/ecs check --ansi", 45 | "fix-cs": "vendor/bin/ecs check --fix --ansi", 46 | "phpstan": "vendor/bin/phpstan analyse --error-format symplify --ansi", 47 | "changelog": "vendor/bin/changelog-linker dump-merges --in-categories --ansi", 48 | "rector": "vendor/bin/rector process --dry-run --ansi" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Contract/OutputEventSubscriberInterface.php: -------------------------------------------------------------------------------- 1 | gitWrapper = $gitWrapper; 35 | $this->process = $process; 36 | $this->gitCommand = $gitCommand; 37 | } 38 | 39 | public function getWrapper(): GitWrapper 40 | { 41 | return $this->gitWrapper; 42 | } 43 | 44 | public function getProcess(): Process 45 | { 46 | return $this->process; 47 | } 48 | 49 | public function getCommand(): GitCommand 50 | { 51 | return $this->gitCommand; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Event/GitBypassEvent.php: -------------------------------------------------------------------------------- 1 | type = $type; 36 | $this->buffer = $buffer; 37 | } 38 | 39 | public function getType(): string 40 | { 41 | return $this->type; 42 | } 43 | 44 | public function getBuffer(): string 45 | { 46 | return $this->buffer; 47 | } 48 | 49 | public function isError(): bool 50 | { 51 | return $this->type === Process::ERR; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Event/GitPrepareEvent.php: -------------------------------------------------------------------------------- 1 | 'handleOutput', 20 | ]; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/EventSubscriber/GitLoggerEventSubscriber.php: -------------------------------------------------------------------------------- 1 | LogLevel::INFO, 28 | GitOutputEvent::class => LogLevel::DEBUG, 29 | GitSuccessEvent::class => LogLevel::INFO, 30 | GitErrorEvent::class => LogLevel::ERROR, 31 | GitBypassEvent::class => LogLevel::INFO, 32 | ]; 33 | 34 | /** 35 | * @var LoggerInterface 36 | */ 37 | private $logger; 38 | 39 | public function __construct(LoggerInterface $logger) 40 | { 41 | $this->logger = $logger; 42 | } 43 | 44 | /** 45 | * Required by interface 46 | */ 47 | public function setLogger(LoggerInterface $logger): void 48 | { 49 | $this->logger = $logger; 50 | } 51 | 52 | public function setLogLevelMapping(string $eventName, string $logLevel): void 53 | { 54 | $this->logLevelMappings[$eventName] = $logLevel; 55 | } 56 | 57 | /** 58 | * Returns the log level mapping for an event. 59 | */ 60 | public function getLogLevelMapping(string $eventName): string 61 | { 62 | if (! isset($this->logLevelMappings[$eventName])) { 63 | throw new GitException(sprintf('Unknown event "%s"', $eventName)); 64 | } 65 | 66 | return $this->logLevelMappings[$eventName]; 67 | } 68 | 69 | /** 70 | * @return int[][]|string[][] 71 | */ 72 | public static function getSubscribedEvents(): array 73 | { 74 | return [ 75 | GitPrepareEvent::class => ['onPrepare', 0], 76 | GitOutputEvent::class => ['handleOutput', 0], 77 | GitSuccessEvent::class => ['onSuccess', 0], 78 | GitErrorEvent::class => ['onError', 0], 79 | GitBypassEvent::class => ['onBypass', 0], 80 | ]; 81 | } 82 | 83 | /** 84 | * Adds a log message using the level defined in the mappings. 85 | * 86 | * @param mixed[] $context 87 | */ 88 | public function log(AbstractGitEvent $gitEvent, string $message, array $context = []): void 89 | { 90 | $method = $this->getLogLevelMapping(get_class($gitEvent)); 91 | $context += [ 92 | 'command' => $gitEvent->getProcess() 93 | ->getCommandLine(), 94 | ]; 95 | 96 | $this->logger->{$method}($message, $context); 97 | } 98 | 99 | public function onPrepare(GitPrepareEvent $gitPrepareEvent): void 100 | { 101 | $this->log($gitPrepareEvent, 'Git command preparing to run'); 102 | } 103 | 104 | public function handleOutput(GitOutputEvent $gitOutputEvent): void 105 | { 106 | $context = [ 107 | 'error' => $gitOutputEvent->isError(), 108 | ]; 109 | $this->log($gitOutputEvent, $gitOutputEvent->getBuffer(), $context); 110 | } 111 | 112 | public function onSuccess(GitSuccessEvent $gitSuccessEvent): void 113 | { 114 | $this->log($gitSuccessEvent, 'Git command successfully run'); 115 | } 116 | 117 | public function onError(GitErrorEvent $gitErrorEvent): void 118 | { 119 | $this->log($gitErrorEvent, 'Error running Git command'); 120 | } 121 | 122 | public function onBypass(GitBypassEvent $gitBypassEvent): void 123 | { 124 | $this->log($gitBypassEvent, 'Git command bypassed'); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/EventSubscriber/StreamOutputEventSubscriber.php: -------------------------------------------------------------------------------- 1 | isError() ? STDERR : STDOUT; 18 | fwrite($handler, $gitOutputEvent->getBuffer()); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Exception/GitException.php: -------------------------------------------------------------------------------- 1 | gitWorkingCopy = clone $gitWorkingCopy; 25 | $gitWorkingCopy->branch([ 26 | 'a' => true, 27 | ]); 28 | } 29 | 30 | /** 31 | * Fetches the branches via the `git branch` command. 32 | * 33 | * @api 34 | * @param bool $onlyRemote Whether to fetch only remote branches, defaults to false which returns all branches. 35 | * @return string[] 36 | */ 37 | public function fetchBranches(bool $onlyRemote = false): array 38 | { 39 | $options = $onlyRemote ? [ 40 | 'r' => true, 41 | ] : [ 42 | 'a' => true, 43 | ]; 44 | $output = $this->gitWorkingCopy->branch($options); 45 | $branches = Strings::split(rtrim($output), "/\r\n|\n|\r/"); 46 | return array_map(function (string $branch): string { 47 | return $this->trimBranch($branch); 48 | }, $branches); 49 | } 50 | 51 | public function trimBranch(string $branch): string 52 | { 53 | return ltrim($branch, ' *'); 54 | } 55 | 56 | public function getIterator(): ArrayIterator 57 | { 58 | $branches = $this->all(); 59 | return new ArrayIterator($branches); 60 | } 61 | 62 | /** 63 | * @api 64 | * @return string[] 65 | */ 66 | public function all(): array 67 | { 68 | return $this->fetchBranches(); 69 | } 70 | 71 | /** 72 | * @return string[] 73 | */ 74 | public function remote(): array 75 | { 76 | return $this->fetchBranches(true); 77 | } 78 | 79 | /** 80 | * @api 81 | * Returns currently active branch (HEAD) of the working copy. 82 | */ 83 | public function head(): string 84 | { 85 | $output = $this->gitWorkingCopy->run(CommandName::REV_PARSE, ['--abbrev-ref', 'HEAD']); 86 | return trim($output); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/GitCommand.php: -------------------------------------------------------------------------------- 1 | command = $command; 57 | 58 | foreach ($argsAndOptions as $argOrOption) { 59 | if (is_array($argOrOption)) { 60 | // If item is array, set it as the options 61 | $this->setOptions($argOrOption); 62 | } else { 63 | // Pass all other as the Git command arguments 64 | $this->addArgument($argOrOption); 65 | } 66 | } 67 | } 68 | 69 | /** 70 | * Returns Git command being run, e.g. "clone", "commit", etc. 71 | * @api 72 | */ 73 | public function getCommand(): string 74 | { 75 | return $this->command; 76 | } 77 | 78 | public function setDirectory(?string $directory): void 79 | { 80 | $this->directory = $directory; 81 | } 82 | 83 | public function getDirectory(): ?string 84 | { 85 | return $this->directory; 86 | } 87 | 88 | /** 89 | * A bool flagging whether to skip running the command. 90 | */ 91 | public function bypass(bool $bypass = true): void 92 | { 93 | $this->bypass = $bypass; 94 | } 95 | 96 | /** 97 | * Set whether to execute the command as-is without escaping it. 98 | */ 99 | public function executeRaw(bool $executeRaw = true): void 100 | { 101 | $this->executeRaw = $executeRaw; 102 | } 103 | 104 | /** 105 | * Returns true if the Git command should be skipped 106 | */ 107 | public function isBypassed(): bool 108 | { 109 | return $this->bypass; 110 | } 111 | 112 | /** 113 | * @param mixed[]|string|true $value The option's value, pass true if the options is a flag. 114 | */ 115 | public function setOption(string $option, $value): void 116 | { 117 | $this->options[$option] = $value; 118 | } 119 | 120 | /** 121 | * @api 122 | * @param mixed[] $options 123 | */ 124 | public function setOptions(array $options): void 125 | { 126 | foreach ($options as $option => $value) { 127 | $this->setOption($option, $value); 128 | } 129 | } 130 | 131 | public function setFlag(string $option): void 132 | { 133 | $this->setOption($option, true); 134 | } 135 | 136 | /** 137 | * @api 138 | */ 139 | public function getOption(string $option, $default = null) 140 | { 141 | return $this->options[$option] ?? $default; 142 | } 143 | 144 | public function addArgument(string $arg): void 145 | { 146 | $this->args[] = $arg; 147 | } 148 | 149 | /** 150 | * Renders the arguments and options for the Git command. 151 | * 152 | * @return string|string[] 153 | */ 154 | public function getCommandLine() 155 | { 156 | if ($this->executeRaw) { 157 | return $this->command; 158 | } 159 | 160 | $command = []; 161 | $parts = array_merge([$this->command], $this->buildOptions(), $this->args); 162 | 163 | foreach ($parts as $part) { 164 | $value = (string) $part; 165 | if (strlen($value) > 0) { 166 | $command[] = $value; 167 | } 168 | } 169 | 170 | return $command; 171 | } 172 | 173 | /** 174 | * Builds the command line options for use in the Git command. 175 | * 176 | * @return mixed[] 177 | */ 178 | private function buildOptions(): array 179 | { 180 | $options = []; 181 | foreach ($this->options as $option => $values) { 182 | foreach ((array) $values as $value) { 183 | // Render the option. 184 | $prefix = strlen($option) !== 1 ? '--' : '-'; 185 | $options[] = $prefix . $option; 186 | 187 | // Render apend the value if the option isn't a flag. 188 | if ($value !== true) { 189 | $options[] = $value; 190 | } 191 | } 192 | } 193 | 194 | return $options; 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /src/GitTags.php: -------------------------------------------------------------------------------- 1 | gitWorkingCopy = clone $gitWorkingCopy; 24 | } 25 | 26 | /** 27 | * Fetches the Tags via the `git branch` command. 28 | * @api 29 | * @return string[] 30 | */ 31 | public function fetchTags(): array 32 | { 33 | $output = $this->gitWorkingCopy->tag([ 34 | 'l' => true, 35 | ]); 36 | $tags = Strings::split(rtrim($output), "/\r\n|\n|\r/"); 37 | return array_map(function (string $branch): string { 38 | return $this->trimTags($branch); 39 | }, $tags); 40 | } 41 | 42 | /** 43 | * Strips unwanted characters from the branch 44 | */ 45 | public function trimTags(string $branch): string 46 | { 47 | return ltrim($branch, ' *'); 48 | } 49 | 50 | public function getIterator(): ArrayIterator 51 | { 52 | $tags = $this->all(); 53 | return new ArrayIterator($tags); 54 | } 55 | 56 | /** 57 | * @api 58 | * @return mixed[] 59 | */ 60 | public function all(): array 61 | { 62 | return $this->fetchTags(); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/GitWorkingCopy.php: -------------------------------------------------------------------------------- 1 | gitWrapper = $gitWrapper; 56 | $this->directory = $directory; 57 | } 58 | 59 | public function getWrapper(): GitWrapper 60 | { 61 | return $this->gitWrapper; 62 | } 63 | 64 | public function getDirectory(): string 65 | { 66 | return $this->directory; 67 | } 68 | 69 | public function setCloned(bool $cloned): void 70 | { 71 | $this->cloned = $cloned; 72 | } 73 | 74 | /** 75 | * Checks whether a repository has already been cloned to this directory. 76 | * 77 | * If the flag is not set, test if it looks like we're at a git directory. 78 | */ 79 | public function isCloned(): bool 80 | { 81 | if ($this->cloned === null) { 82 | $gitDir = $this->directory; 83 | if (is_dir($gitDir . '/.git')) { 84 | $gitDir .= '/.git'; 85 | } 86 | 87 | $this->cloned = is_dir($gitDir . '/objects') && is_dir($gitDir . '/refs') && is_file($gitDir . '/HEAD'); 88 | } 89 | 90 | return $this->cloned; 91 | } 92 | 93 | /** 94 | * Runs a Git command and returns the output. 95 | * 96 | * @param mixed[] $argsAndOptions 97 | */ 98 | public function run(string $command, array $argsAndOptions = [], bool $setDirectory = true): string 99 | { 100 | $command = new GitCommand($command, ...$argsAndOptions); 101 | if ($setDirectory) { 102 | $command->setDirectory($this->directory); 103 | } 104 | 105 | return $this->gitWrapper->run($command); 106 | } 107 | 108 | /** 109 | * Returns the output of a `git status -s` command. 110 | */ 111 | public function getStatus(): string 112 | { 113 | return $this->run(CommandName::STATUS, ['-s']); 114 | } 115 | 116 | /** 117 | * Returns true if there are changes to commit. 118 | */ 119 | public function hasChanges(): bool 120 | { 121 | $output = $this->getStatus(); 122 | return ! empty($output); 123 | } 124 | 125 | /** 126 | * Returns whether HEAD has a remote tracking branch. 127 | */ 128 | public function isTracking(): bool 129 | { 130 | try { 131 | $this->run(CommandName::REV_PARSE, ['@{u}']); 132 | } catch (GitException $gitException) { 133 | return false; 134 | } 135 | 136 | return true; 137 | } 138 | 139 | /** 140 | * Returns whether HEAD is up-to-date with its remote tracking branch. 141 | */ 142 | public function isUpToDate(): bool 143 | { 144 | if (! $this->isTracking()) { 145 | throw new GitException( 146 | 'Error: HEAD does not have a remote tracking branch. Cannot check if it is up-to-date.' 147 | ); 148 | } 149 | 150 | $mergeBase = $this->run(CommandName::MERGE_BASE, ['@', '@{u}']); 151 | $remoteSha = $this->run(CommandName::REV_PARSE, ['@{u}']); 152 | return $mergeBase === $remoteSha; 153 | } 154 | 155 | /** 156 | * Returns whether HEAD is ahead of its remote tracking branch. 157 | * 158 | * If this returns true it means that commits are present locally which have 159 | * not yet been pushed to the remote. 160 | */ 161 | public function isAhead(): bool 162 | { 163 | if (! $this->isTracking()) { 164 | throw new GitException('Error: HEAD does not have a remote tracking branch. Cannot check if it is ahead.'); 165 | } 166 | 167 | $mergeBase = $this->run(CommandName::MERGE_BASE, ['@', '@{u}']); 168 | $localSha = $this->run(CommandName::REV_PARSE, ['@']); 169 | $remoteSha = $this->run(CommandName::REV_PARSE, ['@{u}']); 170 | return $mergeBase === $remoteSha && $localSha !== $remoteSha; 171 | } 172 | 173 | /** 174 | * Returns whether HEAD is behind its remote tracking branch. 175 | * 176 | * If this returns true it means that a pull is needed to bring the branch 177 | * up-to-date with the remote. 178 | */ 179 | public function isBehind(): bool 180 | { 181 | if (! $this->isTracking()) { 182 | throw new GitException('Error: HEAD does not have a remote tracking branch. Cannot check if it is behind.'); 183 | } 184 | 185 | $mergeBase = $this->run(CommandName::MERGE_BASE, ['@', '@{u}']); 186 | $localSha = $this->run(CommandName::REV_PARSE, ['@']); 187 | $remoteSha = $this->run(CommandName::REV_PARSE, ['@{u}']); 188 | return $mergeBase === $localSha && $localSha !== $remoteSha; 189 | } 190 | 191 | /** 192 | * Returns whether HEAD needs to be merged with its remote tracking branch. 193 | * 194 | * If this returns true it means that HEAD has diverged from its remote 195 | * tracking branch; new commits are present locally as well as on the 196 | * remote. 197 | */ 198 | public function needsMerge(): bool 199 | { 200 | if (! $this->isTracking()) { 201 | throw new GitException('Error: HEAD does not have a remote tracking branch. Cannot check if it is behind.'); 202 | } 203 | 204 | $mergeBase = $this->run(CommandName::MERGE_BASE, ['@', '@{u}']); 205 | $localSha = $this->run(CommandName::REV_PARSE, ['@']); 206 | $remoteSha = $this->run(CommandName::REV_PARSE, ['@{u}']); 207 | return $mergeBase !== $localSha && $mergeBase !== $remoteSha; 208 | } 209 | 210 | /** 211 | * Returns a GitBranches object containing information on the repository's 212 | * branches. 213 | */ 214 | public function getBranches(): GitBranches 215 | { 216 | return new GitBranches($this); 217 | } 218 | 219 | /** 220 | * This is synonymous with `git push origin tag v1.2.3`. 221 | * 222 | * @param string $repository The destination of the push operation, which is either a URL or name of 223 | * the remote. Defaults to "origin". 224 | * @param mixed[] $options 225 | */ 226 | public function pushTag(string $tag, string $repository = 'origin', array $options = []): string 227 | { 228 | return $this->push($repository, 'tag', $tag, $options); 229 | } 230 | 231 | /** 232 | * This is synonymous with `git push --tags origin`. 233 | * 234 | * @param string $repository The destination of the push operation, which is either a URL or name of the remote. 235 | * @param mixed[] $options 236 | */ 237 | public function pushTags(string $repository = 'origin', array $options = []): string 238 | { 239 | $options['tags'] = true; 240 | return $this->push($repository, $options); 241 | } 242 | 243 | /** 244 | * Fetches all remotes. 245 | * 246 | * This is synonymous with `git fetch --all`. 247 | * 248 | * @param mixed[] $options 249 | */ 250 | public function fetchAll(array $options = []): string 251 | { 252 | $options['all'] = true; 253 | return $this->fetch($options); 254 | } 255 | 256 | /** 257 | * Create a new branch and check it out. 258 | * 259 | * This is synonymous with `git checkout -b`. 260 | * 261 | * @param mixed[] $options 262 | */ 263 | public function checkoutNewBranch(string $branch, array $options = []): string 264 | { 265 | $options['b'] = true; 266 | return $this->checkout($branch, $options); 267 | } 268 | 269 | /** 270 | * Adds a remote to the repository. 271 | * 272 | * @param mixed[] $options An associative array of options, with the following keys: 273 | * - -f: Boolean, set to true to run git fetch immediately after the 274 | * remote is set up. Defaults to false. 275 | * - --tags: Boolean. By default only the tags from the fetched branches 276 | * are imported when git fetch is run. Set this to true to import every 277 | * tag from the remote repository. Defaults to false. 278 | * - --no-tags: Boolean, when set to true, git fetch does not import tags 279 | * from the remote repository. Defaults to false. 280 | * - -t: Optional array of branch names to track. If left empty, all 281 | * branches will be tracked. 282 | * - -m: Optional name of the master branch to track. This will set up a 283 | * symbolic ref 'refs/remotes//HEAD which points at the specified 284 | * master branch on the remote. When omitted, no symbolic ref will be 285 | * created. 286 | */ 287 | public function addRemote(string $name, string $url, array $options = []): string 288 | { 289 | $this->ensureAddRemoteArgsAreValid($name, $url); 290 | 291 | $args = ['add']; 292 | 293 | // Add boolean options. 294 | foreach (['-f', '--tags', '--no-tags'] as $option) { 295 | if (! empty($options[$option])) { 296 | $args[] = $option; 297 | } 298 | } 299 | 300 | // Add tracking branches. 301 | if (! empty($options[self::_T])) { 302 | foreach ($options[self::_T] as $branch) { 303 | $args[] = self::_T; 304 | $args[] = $branch; 305 | } 306 | } 307 | 308 | // Add master branch. 309 | if (! empty($options[self::_M])) { 310 | $args[] = self::_M; 311 | $args[] = $options[self::_M]; 312 | } 313 | 314 | // Add remote name and URL. 315 | $args[] = $name; 316 | $args[] = $url; 317 | 318 | return $this->remote(...$args); 319 | } 320 | 321 | public function removeRemote(string $name): string 322 | { 323 | return $this->remote('rm', $name); 324 | } 325 | 326 | public function hasRemote(string $name): bool 327 | { 328 | return array_key_exists($name, $this->getRemotes()); 329 | } 330 | 331 | /** 332 | * @return string[] An associative array with the following keys: 333 | * - fetch: the fetch URL. 334 | * - push: the push URL. 335 | */ 336 | public function getRemote(string $name): array 337 | { 338 | if (! $this->hasRemote($name)) { 339 | throw new GitException(sprintf('The remote "%s" does not exist.', $name)); 340 | } 341 | 342 | return $this->getRemotes()[$name]; 343 | } 344 | 345 | /** 346 | * @return string[][] An associative array, keyed by remote name, containing an associative array with keys 347 | * - fetch: the fetch URL. 348 | * - push: the push URL. 349 | */ 350 | public function getRemotes(): array 351 | { 352 | $result = rtrim($this->remote()); 353 | if (empty($result)) { 354 | return []; 355 | } 356 | 357 | $remotes = []; 358 | 359 | $resultLines = $this->splitByNewline($result); 360 | foreach ($resultLines as $remote) { 361 | $remotes[$remote][CommandName::FETCH] = $this->getRemoteUrl($remote); 362 | $remotes[$remote][CommandName::PUSH] = $this->getRemoteUrl($remote, CommandName::PUSH); 363 | } 364 | 365 | return $remotes; 366 | } 367 | 368 | /** 369 | * Returns the fetch or push URL of a given remote. 370 | * 371 | * @param string $operation The operation for which to return the remote. Can be either 'fetch' or 'push'. 372 | */ 373 | public function getRemoteUrl(string $remote, string $operation = CommandName::FETCH): string 374 | { 375 | $argsAndOptions = ['get-url', $remote]; 376 | 377 | if ($operation === CommandName::PUSH) { 378 | $argsAndOptions[] = '--push'; 379 | } 380 | 381 | return rtrim($this->remote(...$argsAndOptions)); 382 | } 383 | 384 | /** 385 | * @code $git->add('some/file.txt'); 386 | * 387 | * @param mixed[] $options 388 | */ 389 | public function add(string $filepattern, array $options = []): string 390 | { 391 | return $this->run(CommandName::ADD, [$filepattern, $options]); 392 | } 393 | 394 | /** 395 | * @code $git->apply('the/file/to/read/the/patch/from'); 396 | * 397 | * @param mixed ...$argsAndOptions 398 | */ 399 | public function apply(...$argsAndOptions): string 400 | { 401 | return $this->run(CommandName::APPLY, $argsAndOptions); 402 | } 403 | 404 | /** 405 | * Find by binary search the change that introduced a bug. 406 | * 407 | * @code $git->bisect('good', '2.6.13-rc2'); 408 | * $git->bisect('view', ['stat' => true]); 409 | * 410 | * @param mixed ...$argsAndOptions 411 | */ 412 | public function bisect(...$argsAndOptions): string 413 | { 414 | return $this->run(CommandName::BISECT, $argsAndOptions); 415 | } 416 | 417 | /** 418 | * @code $git->branch('my2.6.14', 'v2.6.14'); 419 | * $git->branch('origin/html', 'origin/man', ['d' => true, 'r' => 'origin/todo']); 420 | * 421 | * @param mixed ...$argsAndOptions 422 | */ 423 | public function branch(...$argsAndOptions): string 424 | { 425 | return $this->run(CommandName::BRANCH, $argsAndOptions); 426 | } 427 | 428 | /** 429 | * @code $git->checkout('new-branch', ['b' => true]); 430 | * 431 | * @param mixed ...$argsAndOptions 432 | */ 433 | public function checkout(...$argsAndOptions): string 434 | { 435 | return $this->run(CommandName::CHECKOUT, $argsAndOptions); 436 | } 437 | 438 | /** 439 | * Executes a `git clone` command. 440 | * 441 | * @code $git->cloneRepository('git://github.com/cpliakas/git-wrapper.git'); 442 | * 443 | * @param mixed[] $options 444 | */ 445 | public function cloneRepository(string $repository, array $options = []): string 446 | { 447 | $argsAndOptions = [$repository, $this->directory, $options]; 448 | return $this->run(CommandName::CLONE, $argsAndOptions, false); 449 | } 450 | 451 | /** 452 | * Record changes to the repository. If only one argument is passed, it is assumed to be the commit message. 453 | * Therefore `$git->commit('Message');` yields a `git commit -am "Message"` command. 454 | * 455 | * @code $git->commit('My commit message'); 456 | * $git->commit('Makefile', ['m' => 'My commit message']); 457 | * 458 | * @param mixed ...$argsAndOptions 459 | */ 460 | public function commit(...$argsAndOptions): string 461 | { 462 | if (isset($argsAndOptions[0]) && is_string($argsAndOptions[0]) && ! isset($argsAndOptions[1])) { 463 | $argsAndOptions[0] = [ 464 | 'm' => $argsAndOptions[0], 465 | 'a' => true, 466 | ]; 467 | } 468 | 469 | return $this->run(CommandName::COMMIT, $argsAndOptions); 470 | } 471 | 472 | /** 473 | * @code $git->config('user.email', 'testing@email.com'); 474 | * $git->config('user.name', 'Chris Pliakas'); 475 | * 476 | * @param mixed ...$argsAndOptions 477 | */ 478 | public function config(...$argsAndOptions): string 479 | { 480 | return $this->run(CommandName::CONFIG, $argsAndOptions); 481 | } 482 | 483 | /** 484 | * @code $git->diff(); 485 | * $git->diff('topic', 'master'); 486 | * 487 | * @param mixed ...$argsAndOptions 488 | */ 489 | public function diff(...$argsAndOptions): string 490 | { 491 | return $this->run(CommandName::DIFF, $argsAndOptions); 492 | } 493 | 494 | /** 495 | * @code $git->fetch('origin'); 496 | * $git->fetch(['all' => true]); 497 | * 498 | * @api 499 | * @param mixed ...$argsAndOptions 500 | */ 501 | public function fetch(...$argsAndOptions): string 502 | { 503 | return $this->run(CommandName::FETCH, $argsAndOptions); 504 | } 505 | 506 | /** 507 | * Print lines matching a pattern. 508 | * 509 | * @code $git->grep('time_t', '--', '*.[ch]'); 510 | * 511 | * @param mixed ...$argsAndOptions 512 | */ 513 | public function grep(...$argsAndOptions): string 514 | { 515 | return $this->run(CommandName::GREP, $argsAndOptions); 516 | } 517 | 518 | /** 519 | * Create an empty git repository or reinitialize an existing one. 520 | * 521 | * @code $git->init(['bare' => true]); 522 | * 523 | * @param mixed[] $options 524 | */ 525 | public function init(array $options = []): string 526 | { 527 | $argsAndOptions = [$this->directory, $options]; 528 | return $this->run(CommandName::INIT, $argsAndOptions, false); 529 | } 530 | 531 | /** 532 | * @code $git->log(['no-merges' => true]); 533 | * $git->log('v2.6.12..', 'include/scsi', 'drivers/scsi'); 534 | * 535 | * @param mixed ...$argsAndOptions 536 | */ 537 | public function log(...$argsAndOptions): string 538 | { 539 | return $this->run(CommandName::LOG, $argsAndOptions); 540 | } 541 | 542 | /** 543 | * @code $git->merge('fixes', 'enhancements'); 544 | * 545 | * @param mixed ...$argsAndOptions 546 | */ 547 | public function merge(...$argsAndOptions): string 548 | { 549 | return $this->run(CommandName::MERGE, $argsAndOptions); 550 | } 551 | 552 | /** 553 | * @code $git->mv('orig.txt', 'dest.txt'); 554 | * 555 | * @param mixed[] $options 556 | */ 557 | public function mv(string $source, string $destination, array $options = []): string 558 | { 559 | $argsAndOptions = [$source, $destination, $options]; 560 | return $this->run(CommandName::MV, $argsAndOptions); 561 | } 562 | 563 | /** 564 | * @code $git->pull('upstream', 'master'); 565 | * 566 | * @param mixed ...$argsAndOptions 567 | */ 568 | public function pull(...$argsAndOptions): string 569 | { 570 | return $this->run(CommandName::PULL, $argsAndOptions); 571 | } 572 | 573 | /** 574 | * @code $git->push('upstream', 'master'); 575 | * 576 | * @param mixed ...$argsAndOptions 577 | */ 578 | public function push(...$argsAndOptions): string 579 | { 580 | return $this->run(CommandName::PUSH, $argsAndOptions); 581 | } 582 | 583 | /** 584 | * @code $git->rebase('subsystem@{1}', ['onto' => 'subsystem']); 585 | * 586 | * @param mixed ...$argsAndOptions 587 | */ 588 | public function rebase(...$argsAndOptions): string 589 | { 590 | return $this->run(CommandName::REBASE, $argsAndOptions); 591 | } 592 | 593 | /** 594 | * @code $git->remote('add', 'upstream', 'git://github.com/cpliakas/git-wrapper.git'); 595 | * 596 | * @param mixed ...$argsAndOptions 597 | */ 598 | public function remote(...$argsAndOptions): string 599 | { 600 | return $this->run(CommandName::REMOTE, $argsAndOptions); 601 | } 602 | 603 | /** 604 | * @code $git->reset(['hard' => true]); 605 | * 606 | * @param mixed ...$argsAndOptions 607 | */ 608 | public function reset(...$argsAndOptions): string 609 | { 610 | return $this->run(CommandName::RESET, $argsAndOptions); 611 | } 612 | 613 | /** 614 | * @code $git->rm('oldfile.txt'); 615 | * 616 | * @param mixed[] $options 617 | */ 618 | public function rm(string $filepattern, array $options = []): string 619 | { 620 | $args = [$filepattern, $options]; 621 | return $this->run(CommandName::RM, $args); 622 | } 623 | 624 | /** 625 | * @code $git->show('v1.0.0'); 626 | * 627 | * @param mixed[] $options 628 | */ 629 | public function show(string $object, array $options = []): string 630 | { 631 | $args = [$object, $options]; 632 | return $this->run(CommandName::SHOW, $args); 633 | } 634 | 635 | /** 636 | * @code $git->status(['s' => true]); 637 | * 638 | * @param mixed ...$argsAndOptions 639 | */ 640 | public function status(...$argsAndOptions): string 641 | { 642 | return $this->run(CommandName::STATUS, $argsAndOptions); 643 | } 644 | 645 | /** 646 | * @code $git->tag('v1.0.0'); 647 | * 648 | * @param mixed ...$argsAndOptions 649 | */ 650 | public function tag(...$argsAndOptions): string 651 | { 652 | return $this->run(CommandName::TAG, $argsAndOptions); 653 | } 654 | 655 | /** 656 | * @code $git->clean('-d', '-f'); 657 | * 658 | * @param mixed ...$argsAndOptions 659 | */ 660 | public function clean(...$argsAndOptions): string 661 | { 662 | return $this->run(CommandName::CLEAN, $argsAndOptions); 663 | } 664 | 665 | /** 666 | * @code $git->archive('HEAD', ['o' => '/path/to/archive']); 667 | * 668 | * @param mixed ...$argsAndOptions 669 | */ 670 | public function archive(...$argsAndOptions): string 671 | { 672 | return $this->run(CommandName::ARCHIVE, $argsAndOptions); 673 | } 674 | 675 | /** 676 | * @api 677 | * Returns a GitTags object containing information on the repository's tags. 678 | */ 679 | public function tags(): GitTags 680 | { 681 | return new GitTags($this); 682 | } 683 | 684 | private function ensureAddRemoteArgsAreValid(string $name, string $url): void 685 | { 686 | if (empty($name)) { 687 | throw new GitException('Cannot add remote without a name.'); 688 | } 689 | 690 | if (empty($url)) { 691 | throw new GitException('Cannot add remote without a URL.'); 692 | } 693 | } 694 | 695 | private function splitByNewline(string $string): array 696 | { 697 | return Strings::split($string, '#\R#'); 698 | } 699 | } 700 | -------------------------------------------------------------------------------- /src/GitWrapper.php: -------------------------------------------------------------------------------- 1 | find('git'); 64 | if (! $gitBinary) { 65 | throw new GitException('Unable to find the Git executable.'); 66 | } 67 | } 68 | 69 | $this->setGitBinary($gitBinary); 70 | 71 | $this->eventDispatcher = new EventDispatcher(); 72 | } 73 | 74 | public function getDispatcher(): EventDispatcherInterface 75 | { 76 | return $this->eventDispatcher; 77 | } 78 | 79 | public function setDispatcher(EventDispatcherInterface $eventDispatcher): void 80 | { 81 | $this->eventDispatcher = $eventDispatcher; 82 | } 83 | 84 | public function setGitBinary(string $gitBinary): void 85 | { 86 | $this->gitBinary = $gitBinary; 87 | } 88 | 89 | public function getGitBinary(): string 90 | { 91 | return $this->gitBinary; 92 | } 93 | 94 | public function setEnvVar(string $var, $value): void 95 | { 96 | $this->env[$var] = $value; 97 | } 98 | 99 | public function unsetEnvVar(string $var): void 100 | { 101 | unset($this->env[$var]); 102 | } 103 | 104 | /** 105 | * Returns an environment variable that is defined only in the scope of the 106 | * Git command. 107 | * 108 | * @param string $var The name of the environment variable, e.g. "HOME", "GIT_SSH". 109 | * @param mixed $default The value returned if the environment variable is not set, defaults to 110 | * null. 111 | */ 112 | public function getEnvVar(string $var, $default = null) 113 | { 114 | return $this->env[$var] ?? $default; 115 | } 116 | 117 | /** 118 | * @return string[] 119 | */ 120 | public function getEnvVars(): array 121 | { 122 | return $this->env; 123 | } 124 | 125 | public function setTimeout(int $timeout): void 126 | { 127 | $this->timeout = $timeout; 128 | } 129 | 130 | public function getTimeout(): int 131 | { 132 | return $this->timeout; 133 | } 134 | 135 | /** 136 | * Set an alternate private key used to connect to the repository. 137 | * 138 | * This method sets the GIT_SSH environment variable to use the wrapper 139 | * script included with this library. It also sets the custom GIT_SSH_KEY 140 | * and GIT_SSH_PORT environment variables that are used by the script. 141 | * 142 | * @param string|null $wrapper Path the the GIT_SSH wrapper script, defaults to null which uses the 143 | * script included with this library. 144 | */ 145 | public function setPrivateKey(string $privateKey, int $port = 22, ?string $wrapper = null): void 146 | { 147 | if ($wrapper === null) { 148 | $wrapper = __DIR__ . '/../bin/git-ssh-wrapper.sh'; 149 | } 150 | 151 | $wrapperPath = realpath($wrapper); 152 | if ($wrapperPath === false) { 153 | throw new GitException('Path to GIT_SSH wrapper script could not be resolved: ' . $wrapper); 154 | } 155 | 156 | $privateKeyPath = realpath($privateKey); 157 | if ($privateKeyPath === false) { 158 | throw new GitException('Path private key could not be resolved: ' . $privateKey); 159 | } 160 | 161 | $this->setEnvVar('GIT_SSH', $wrapperPath); 162 | $this->setEnvVar('GIT_SSH_KEY', $privateKeyPath); 163 | $this->setEnvVar('GIT_SSH_PORT', $port); 164 | } 165 | 166 | /** 167 | * Unsets the private key by removing the appropriate environment variables. 168 | */ 169 | public function unsetPrivateKey(): void 170 | { 171 | $this->unsetEnvVar('GIT_SSH'); 172 | $this->unsetEnvVar('GIT_SSH_KEY'); 173 | $this->unsetEnvVar('GIT_SSH_PORT'); 174 | } 175 | 176 | /** 177 | * @api 178 | */ 179 | public function addOutputEventSubscriber(AbstractOutputEventSubscriber $gitOutputEventSubscriber): void 180 | { 181 | $this->eventDispatcher->addSubscriber($gitOutputEventSubscriber); 182 | } 183 | 184 | public function addLoggerEventSubscriber(GitLoggerEventSubscriber $gitLoggerEventSubscriber): void 185 | { 186 | $this->eventDispatcher->addSubscriber($gitLoggerEventSubscriber); 187 | } 188 | 189 | /** 190 | * @api 191 | */ 192 | public function removeOutputEventSubscriber(AbstractOutputEventSubscriber $gitOutputEventSubscriber): void 193 | { 194 | $this->eventDispatcher->removeSubscriber($gitOutputEventSubscriber); 195 | } 196 | 197 | /** 198 | * @api 199 | * Set whether or not to stream real-time output to STDOUT and STDERR. 200 | */ 201 | public function streamOutput(bool $streamOutput = true): void 202 | { 203 | if ($streamOutput && $this->outputEventSubscriber === null) { 204 | $this->outputEventSubscriber = new StreamOutputEventSubscriber(); 205 | $this->addOutputEventSubscriber($this->outputEventSubscriber); 206 | } 207 | 208 | if (! $streamOutput && $this->outputEventSubscriber !== null) { 209 | $this->removeOutputEventSubscriber($this->outputEventSubscriber); 210 | unset($this->outputEventSubscriber); 211 | } 212 | } 213 | 214 | /** 215 | * Returns an object that interacts with a working copy. 216 | * 217 | * @param string $directory Path to the directory containing the working copy. 218 | */ 219 | public function workingCopy(string $directory): GitWorkingCopy 220 | { 221 | return new GitWorkingCopy($this, $directory); 222 | } 223 | 224 | /** 225 | * Returns the version of the installed Git client. 226 | */ 227 | public function version(): string 228 | { 229 | return $this->git('--version'); 230 | } 231 | 232 | /** 233 | * Executes a `git init` command. 234 | * 235 | * Create an empty git repository or reinitialize an existing one. 236 | * 237 | * @param mixed[] $options An associative array of command line options. 238 | */ 239 | public function init(string $directory, array $options = []): GitWorkingCopy 240 | { 241 | $git = $this->workingCopy($directory); 242 | $git->init($options); 243 | $git->setCloned(true); 244 | 245 | return $git; 246 | } 247 | 248 | /** 249 | * Executes a `git clone` command and returns a working copy object. 250 | * 251 | * Clone a repository into a new directory. Use @see GitWorkingCopy::cloneRepository() 252 | * instead for more readable code. 253 | * 254 | * @param string $directory The directory that the repository will be cloned into. If null is 255 | * passed, the directory will be generated from the URL with @see GitStrings::parseRepositoryName(). 256 | * @param mixed[] $options 257 | */ 258 | public function cloneRepository(string $repository, ?string $directory = null, array $options = []): GitWorkingCopy 259 | { 260 | if ($directory === null) { 261 | $directory = GitStrings::parseRepositoryName($repository); 262 | } 263 | 264 | $git = $this->workingCopy($directory); 265 | $git->cloneRepository($repository, $options); 266 | $git->setCloned(true); 267 | return $git; 268 | } 269 | 270 | /** 271 | * The command is simply a raw command line entry for everything after the Git binary. 272 | * For example, a `git config -l` command would be passed as `config -l` via the first argument of this method. 273 | * 274 | * @return string The STDOUT returned by the Git command. 275 | */ 276 | public function git(string $commandLine, ?string $cwd = null): string 277 | { 278 | $command = new GitCommand($commandLine); 279 | $command->executeRaw(is_string($commandLine)); 280 | $command->setDirectory($cwd); 281 | return $this->run($command); 282 | } 283 | 284 | /** 285 | * @return string The STDOUT returned by the Git command. 286 | */ 287 | public function run(GitCommand $gitCommand, ?string $cwd = null): string 288 | { 289 | $process = new GitProcess($this, $gitCommand, $cwd); 290 | $process->run(function ($type, $buffer) use ($process, $gitCommand): void { 291 | $event = new GitOutputEvent($this, $process, $gitCommand, $type, $buffer); 292 | $this->eventDispatcher->dispatch($event); 293 | }); 294 | 295 | return $gitCommand->isBypassed() ? '' : $process->getOutput(); 296 | } 297 | } 298 | -------------------------------------------------------------------------------- /src/Process/GitProcess.php: -------------------------------------------------------------------------------- 1 | gitWrapper = $gitWrapper; 33 | $this->gitCommand = $gitCommand; 34 | 35 | // Build the command line options, flags, and arguments. 36 | $commandLine = $gitCommand->getCommandLine(); 37 | $gitBinary = $gitWrapper->getGitBinary(); 38 | 39 | // Support for executing an arbitrary git command. 40 | if (is_string($commandLine)) { 41 | $commandLine = explode(' ', $commandLine); 42 | } 43 | 44 | array_unshift($commandLine, $gitBinary); 45 | 46 | // Resolve the working directory of the Git process. Use the directory 47 | // in the command object if it exists. 48 | $cwd = $this->resolveWorkingDirectory($cwd, $gitCommand); 49 | 50 | // Finalize the environment variables, an empty array is converted 51 | // to null which enherits the environment of the PHP process. 52 | $env = $gitWrapper->getEnvVars(); 53 | if ($env === []) { 54 | $env = null; 55 | } 56 | 57 | parent::__construct($commandLine, $cwd, $env, null, (float) $gitWrapper->getTimeout()); 58 | } 59 | 60 | public function start(?callable $callback = null, array $env = []): void 61 | { 62 | $gitPrepareEvent = new GitPrepareEvent($this->gitWrapper, $this, $this->gitCommand); 63 | $this->dispatchEvent($gitPrepareEvent); 64 | 65 | if (! $this->gitCommand->isBypassed()) { 66 | parent::start($callback, $env); 67 | } else { 68 | $gitBypassEvent = new GitBypassEvent($this->gitWrapper, $this, $this->gitCommand); 69 | $this->dispatchEvent($gitBypassEvent); 70 | } 71 | } 72 | 73 | public function wait(?callable $callback = null): int 74 | { 75 | if ($this->gitCommand->isBypassed()) { 76 | return -1; 77 | } 78 | 79 | try { 80 | $exitCode = parent::wait($callback); 81 | 82 | if ($this->isSuccessful()) { 83 | $gitSuccessEvent = new GitSuccessEvent($this->gitWrapper, $this, $this->gitCommand); 84 | $this->dispatchEvent($gitSuccessEvent); 85 | } else { 86 | $output = $this->getErrorOutput(); 87 | 88 | if (trim($output) === '') { 89 | $output = $this->getOutput(); 90 | } 91 | 92 | throw new GitException($output); 93 | } 94 | } catch (RuntimeException $runtimeException) { 95 | $gitErrorEvent = new GitErrorEvent($this->gitWrapper, $this, $this->gitCommand); 96 | $this->dispatchEvent($gitErrorEvent); 97 | 98 | throw new GitException($runtimeException->getMessage(), $runtimeException->getCode(), $runtimeException); 99 | } 100 | 101 | return $exitCode; 102 | } 103 | 104 | private function resolveWorkingDirectory(?string $cwd, GitCommand $gitCommand): ?string 105 | { 106 | if ($cwd !== null) { 107 | return $cwd; 108 | } 109 | 110 | $directory = $gitCommand->getDirectory(); 111 | if ($directory === null) { 112 | return $cwd; 113 | } 114 | 115 | $cwd = realpath($directory); 116 | if ($cwd === false) { 117 | throw new GitException('Path to working directory could not be resolved: ' . $directory); 118 | } 119 | 120 | return $cwd; 121 | } 122 | 123 | private function dispatchEvent(Event $event): void 124 | { 125 | $this->gitWrapper->getDispatcher() 126 | ->dispatch($event); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/Strings/GitStrings.php: -------------------------------------------------------------------------------- 1 |