├── .gitignore ├── bin └── phploy.phar ├── phploy.php ├── src ├── sample.ini ├── Ansi.php └── PHPloy.php ├── composer.json ├── phploy.bat ├── contributing.md ├── deploy.ini └── readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | /dev 3 | composer.lock 4 | *~ 5 | *.swp 6 | -------------------------------------------------------------------------------- /bin/phploy.phar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fg/PHPloy/master/bin/phploy.phar -------------------------------------------------------------------------------- /phploy.php: -------------------------------------------------------------------------------- 1 | Oh Snap: {$e->getMessage()}\r\n"); 14 | } 15 | -------------------------------------------------------------------------------- /src/sample.ini: -------------------------------------------------------------------------------- 1 | ; NOTE: If a value in the .ini file contains any non-alphanumeric 2 | ; characters it needs to be enclosed in double-quotes ("). 3 | 4 | ;[staging] 5 | ; quickmode = ftp://username:password@staging-example.com:21/path/to/installation 6 | 7 | [staging] 8 | scheme = sftp 9 | user = username 10 | pass = password 11 | host = staging-example.com 12 | path = /path/to/installation 13 | port = 22 14 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "banago/phploy", 3 | "description": "PHPloy - Incremental Git (S)FTP deployment tool that supports submodules, multiple servers and rollbacks.", 4 | "license": "MIT", 5 | "keywords": ["banago", "deploy", "ftp", "sftp", "ssh", "git"], 6 | "authors": [ 7 | { 8 | "name": "Baki Goxhaj", 9 | "email": "banago@gmail.com", 10 | "homepage": "http://wplancer.com", 11 | "role": "Developer" 12 | } 13 | ], 14 | "require": { 15 | "php": ">=5.4.0", 16 | "banago/bridge": "dev-master", 17 | "jakeasmith/http_build_url": "dev-master" 18 | }, 19 | "require-dev": {}, 20 | "autoload": { 21 | "psr-4": { 22 | "Banago\\PHPloy\\": "src" 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /phploy.bat: -------------------------------------------------------------------------------- 1 | :: To run phploy globally (from any folder), either add this folder to your system's PATH 2 | :: or copy this BAT file somewhere into your system's PATH, eg. C:\WINDOWS 3 | :: 4 | :: Note you will need PHP.exe somewhere on your system also, and if it's not also 5 | :: in your PATH variable, you will need to specify the full path to it below 6 | :: 7 | :: If you're not sure how to edit your system's path variable: 8 | :: - Press WIN+PAUSE to open the System Control Panel screen, 9 | :: - Choose "Advanced System Settings" 10 | :: - Click "Environment Variables" 11 | :: - Find "Path" in the bottom section, and add the necessary folder(s) to the list, 12 | :: separated by semi-colons 13 | :: eg. C:\Windows;C:\Windows\System32;C:\path\to\php.exe;C:\path\to\phploy 14 | 15 | @ECHO OFF 16 | 17 | :: Set the console code page to use UTF-8 18 | chcp 65001 > NUL 19 | 20 | php C:\path\to\phploy %* 21 | -------------------------------------------------------------------------------- /contributing.md: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | Please know that any contribution is welcome. However, before proposing a pull request, please check the following: 5 | 6 | * Your code should follow the [PSR-2 coding standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md). Run `make sniff` to check that the coding standards are followed, and use [php-cs-fixer](https://github.com/fabpot/PHP-CS-Fixer) to fix inconsistencies. 7 | * If you commit a new feature, be prepared to help maintaining it. Watch the project on GitHub, and please comment on issues or PRs regarding the feature you contributed. 8 | * You should test your feature well. 9 | * You should run `php build` and commit the PHAR file so it is part of your pull request. You may need to change `phar.readonly` php.ini setting to `0` or run the command as `php -d phar.readonly=0 build`. 10 | 11 | Once your code is merged, it is available for free to everybody under the MIT License. Publishing your Pull Request on the PHPloy GitHub repository means that you agree with this license for your contribution. 12 | 13 | Thank you for your contribution! PHPloy wouldn't be so great without you. 14 | -------------------------------------------------------------------------------- /deploy.ini: -------------------------------------------------------------------------------- 1 | ; This is a sample deploy.ini file. You can specify as many 2 | ; servers as you need and use normal or quickmode configuration. 3 | ; 4 | ; NOTE: If a value in the .ini file contains any non-alphanumeric 5 | ; characters it needs to be enclosed in double-quotes ("). 6 | 7 | [staging] 8 | scheme = sftp 9 | user = username 10 | ; When connecting via SFTP, you can opt for password-based authentication: 11 | pass = password 12 | ; Or private key-based authentication: 13 | pubkey = /path/to/public/key 14 | privkey = /path/to/private/key 15 | ; If the private key is encrypted, you must also provide the passphrase: 16 | keypass = passphrase 17 | host = staging-example.com 18 | path = /path/to/installation 19 | port = 22 20 | ; Files that should be ignored and not uploaded to your server, but still tracked in your repository 21 | skip[] = 'src/*.scss' 22 | skip[] = '*.ini' 23 | purge[] = "cache/" 24 | purge[] = "/public_html/wp-content/themes/base/cache/" 25 | 26 | [production] 27 | quickmode = ftp://username:password@production-example.com:21/path/to/installation 28 | passive = true 29 | ; Files that should be ignored and not uploaded to your server, but still tracked in your repository 30 | skip[] = 'libs/*' 31 | skip[] = 'config/*' 32 | skip[] = 'src/*.scss' 33 | purge[] = "cache/" 34 | purge[] = "/public_html/wp-content/themes/base/cache/" 35 | -------------------------------------------------------------------------------- /src/Ansi.php: -------------------------------------------------------------------------------- 1 | 11 | * 12 | * 13 | * 14 | * 15 | * 16 | * 17 | * 18 | * 19 | * 20 | * 21 | * 22 | * 23 | * 24 | * 25 | * 26 | * 27 | * 28 | * 29 | * 30 | * 31 | * 32 | * 33 | * 34 | * 35 | * Not visible on Windows 36 | * Not visible on Windows 37 | * Clears all colors and styles (required) 38 | * 39 | * Note: we don't use commands like bold-off, underline-off as it was introduced 40 | * in ANSI 2.50+ and does not currently display on Windows using ANSICON 41 | */ 42 | 43 | namespace Banago\PHPloy; 44 | 45 | class Ansi 46 | { 47 | /** 48 | * Whether color codes are enabled or not 49 | * 50 | * Valid options: 51 | * null - Auto-detected. Color codes will be enabled on all systems except Windows, unless it 52 | * has a valid ANSICON environment variable 53 | * (indicating that ANSICON is installed and running) 54 | * false - will strip all tags and NOT output any ANSI color codes 55 | * true - will always output color codes 56 | */ 57 | public static $enabled = null; 58 | 59 | public static $tags = array( 60 | '' => "\033[0;30m", 61 | '' => "\033[1;31m", 62 | '' => "\033[1;32m", 63 | '' => "\033[1;33m", 64 | '' => "\033[1;34m", 65 | '' => "\033[1;35m", 66 | '' => "\033[1;36m", 67 | '' => "\033[1;37m", 68 | '' => "\033[0;37m", 69 | '' => "\033[0;31m", 70 | '' => "\033[0;32m", 71 | '' => "\033[0;33m", 72 | '' => "\033[0;34m", 73 | '' => "\033[0;35m", 74 | '' => "\033[0;36m", 75 | '' => "\033[0;37m", 76 | '' => "\033[1;30m", 77 | '' => "\033[40m", 78 | '' => "\033[41m", 79 | '' => "\033[42m", 80 | '' => "\033[43m", 81 | '' => "\033[44m", 82 | '' => "\033[45m", 83 | '' => "\033[46m", 84 | '' => "\033[47m", 85 | '' => "\033[1m", 86 | '' => "\033[3m", 87 | '' => "\033[0m", 88 | ); 89 | 90 | /** 91 | * This is the primary function for converting tags to ANSI color codes 92 | * (see the class description for the supported tags) 93 | * 94 | * For safety, this function always appends a at the end, otherwise the console may stick 95 | * permanently in the colors you have used. 96 | * 97 | * @param string $string 98 | * @return string 99 | */ 100 | public static function tagsToColors($string) 101 | { 102 | if (static::$enabled === null) { 103 | static::$enabled = !static::isWindows() || static::isAnsiCon(); 104 | } 105 | 106 | if (!static::$enabled) { 107 | // Strip tags (replace them with an empty string) 108 | return str_replace(array_keys(static::$tags), '', $string); 109 | } 110 | 111 | // We always add a at the end of each string so that any output following doesn't continue the same styling 112 | $string .= ''; 113 | return str_replace(array_keys(static::$tags), static::$tags, $string); 114 | } 115 | 116 | public static function isWindows() 117 | { 118 | return strtoupper(substr(PHP_OS, 0, 3)) === 'WIN'; 119 | } 120 | 121 | public static function isAnsiCon() 122 | { 123 | return !empty($_SERVER['ANSICON']) 124 | && substr($_SERVER['ANSICON'], 0, 1) != '0'; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # PHPloy 2 | 3 | **Version 3.5.6** 4 | 5 | PHPloy is an incremental Git FTP and SFTP deployment tool. By keeping track of the state of the remote server(s) it deploys only the files that were committed since the last deployment. PHPloy supports submodules, sub-submodules, deploying to multiple servers and rollbacks. 6 | 7 | ## Requirements 8 | 9 | * PHP 5.4+ command line interpreter (CLI) 10 | * Git 1.7.12.4+ 11 | * [SSH2 PECL extension](https://php.net/manual/en/ssh2.installation.php) (SFTP) 12 | 13 | Windows users can optionally download [ANSICON](https://github.com/adoxa/ansicon/releases) to enable the display of colors in the command prompt. Install it by running `ansicon -i` from a command prompt or "Run" window. 14 | 15 | ## Usage 16 | 17 | As any script, you can use PHPloy globally, from your `bin` directory or locally, from your project directory: 18 | 19 | ### Using PHPloy locally (per project) 20 | 21 | 1. Drop `phploy.phar` into your project. 22 | 2. Run `phploy.phar --init` in the terminal to create a sample `deploy.ini` file or create one manually. 23 | 3. Run `php phploy.phar` in terminal. 24 | 25 | Please note that the sample `deploy.ini` file does not contain all the possible options. It is meant to provide a quick setup option for a simple deployment. For the full set of options, please see the example `deploy.ini` bellow. 26 | 27 | ### Using PHPloy globally in Linux 28 | 29 | 1. Drop `phploy.phar` into `/usr/local/bin` and make it executable by running `sudo chmod +x phploy`. 30 | 2. Run `phploy --init` in the terminal to create the `deploy.ini` file inside your project folder or create one manually. 31 | 3. Run `phploy` in terminal. 32 | 33 | Or: 34 | 35 | You can add a symlink/symbolic link to `phploy.phar` in your `/usr/local/bin`, that way you can still update PHPloy, and you won't have to copy/paste new files every time. 36 | 37 | You can create the symlink very easy with this command: 38 | `sudo ln -s ~/PHPloy/bin/phploy.phar /usr/local/bin/phploy` 39 | In this case, I've placed the PHPloy folder in my home directory, you can just change the path to wherever PHPloy is placed. 40 | 41 | Then you can run `phploy` in terminal. 42 | 43 | ### Installing PHPloy globally in Windows 44 | 45 | 1. Extract or clone the PHPloy files into a folder of your choice 46 | 2. Ensure `phploy.bat` can find the path to `php.exe` by either: 47 | * Adding the path to `php.exe` to your system path 48 | * Manually adding the path inside `phploy.bat` 49 | 3. Add the PHPloy folder to your system path 50 | 4. Run `phploy` from the command prompt (from your repository folder) 51 | 52 | Adding folders to your system path means that you can execute an application from any folder, and not have to specify the full path to it. To add folders to your system path: 53 | 54 | 1. From your "Start" menu right-click "Computer" and click "Properties", or press Windows+Pause to open the "System" window. 55 | 2. Click "Advanced system settings". 56 | 3. Click "Environment Variables". 57 | 4. Under "System variables" select the "Path" variable and click "Edit". 58 | 5. Add a semicolon `;` at the end of the value, keeping all existing values intact. Add the location of the PHPloy folder (spaces are allowed and no quotes are required). 59 | 6. Click "OK". 60 | 61 | ## deploy.ini 62 | 63 | The `deploy.ini` file hold your credentials and it must be in the root directory of your project. Use as many servers as you need and whichever configuration type you prefer. 64 | 65 | ```ini 66 | ; This is a sample deploy.ini file. You can specify as many 67 | ; servers as you need and use normal or quickmode configuration. 68 | ; 69 | ; NOTE: If a value in the .ini file contains any non-alphanumeric 70 | ; characters it needs to be enclosed in double-quotes ("). 71 | 72 | [staging] 73 | scheme = sftp 74 | user = username 75 | ; When connecting via SFTP, you can opt for password-based authentication: 76 | pass = password 77 | ; Or private key-based authentication: 78 | pubkey = /path/to/public/key 79 | privkey = /path/to/private/key 80 | ; If the private key is encrypted, you must also provide the passphrase: 81 | keypass = passphrase 82 | host = staging-example.com 83 | path = /path/to/installation 84 | port = 22 85 | passive = true 86 | ; You can specify a list of patterns of files to be uploaded. 87 | ; Only files that match at least one of the patterns will be uploaded to the server. 88 | ; If a list of include patterns is not present, all files are considered 89 | ; by default (as if include[] = '*' was specified). 90 | include[] = 'public_html/*' 91 | ; Files that should be ignored and not uploaded to your server, but still tracked in your repository 92 | ; This takes precedence over include[] 93 | skip[] = 'src/*.scss' 94 | skip[] = '*.ini' 95 | skip[] = 'public_html/ignored/*' 96 | 97 | [production] 98 | quickmode = ftp://username:password@production-example.com:21/path/to/installation 99 | passive = true 100 | ; Files that should be ignored and not uploaded to your server, but still tracked in your repository 101 | skip[] = 'libs/*' 102 | skip[] = 'config/*' 103 | skip[] = 'src/*.scss' 104 | ``` 105 | 106 | If your password is missing in the `deploy.ini` file, PHPloy will interactively ask you for your password. 107 | 108 | The first time it's executed, PHPloy will assume that your deployment server is empty, and will upload **all** the files of your project. If the remote server already has a copy of the files, you can specify which revision it is on using the `--sync` command (see below). 109 | 110 | ## Multiple servers 111 | 112 | PHPloy allows you to configure multiple servers in the deploy file and deploy to any of them with ease. 113 | 114 | By default PHPloy will deploy to **all** specified servers. Alternatively, if an entry named `default` exists in your server configuration, PHPloy will default to that server configuration. To specify one single server, run: 115 | 116 | phploy -s servername 117 | 118 | Or: 119 | 120 | phploy --server servername 121 | 122 | `servername` stands for the name you have given to the server in the `deploy.ini` configuration file. 123 | 124 | If you have a `default` server configured, you can specify to deploy to **all** configured servers by running: 125 | 126 | phploy --all 127 | 128 | ## Rollbacks 129 | 130 | > Warning: the `--rollback` option does not currently update your submodules correctly. Until this is fixed, we recommend you first checkout the revision you would like to deploy and update its submodules, before running `phploy`. 131 | 132 | PHPloy allows you to roll back to an earlier version when you need to. Rolling back is very easy. 133 | 134 | To roll back to the previous commit, you just run: 135 | 136 | phploy --rollback 137 | 138 | To roll back to whatever commit you want, you run: 139 | 140 | phploy --rollback="commit-hash-goes-here" 141 | 142 | When you run a rollback, the files in your working copy will revert **temporarily** to the version of the rollback you are deploying. When the deployment has finished, everything will go back as it was. 143 | 144 | Note that there is not a short version of `--rollback`. 145 | 146 | ## Listing changed files 147 | 148 | PHPloy allows you to see what files are going to be uploaded/deleted before you actually push them. Just run: 149 | 150 | phploy -l 151 | 152 | Or: 153 | 154 | phploy --list 155 | 156 | ## Upload other files 157 | 158 | To upload all files, even the ones not tracked by git (e.g. the Composer vendor directory), run: 159 | 160 | phploy -o 161 | 162 | Or: 163 | 164 | phploy --others 165 | 166 | Please keep in mind that **all** files not excluded in your `deploy.ini` will be uploaded. 167 | 168 | ## Updating or "syncing" the remote revision 169 | 170 | If you want to update the `.revision` file on the server to match your current local revision, run: 171 | 172 | phploy --sync 173 | 174 | If you want to set it to a previous commit revision, just specify the revision like this: 175 | 176 | phploy --sync="your-revision-hash-here" 177 | 178 | ## Submodules 179 | 180 | Submodules are supported, but are turned off by default since you don't expect them to change very often and you only update them once in a while. To run a deployment with submodule scanning, add the `--submodules` parameter to the command: 181 | 182 | phploy --submodules 183 | 184 | ## Purging 185 | 186 | In many cases, we need to purge the contents of a directory after a deployment. This can be achieved by specifying the directories in `deploy.ini` like this: 187 | 188 | ```ini 189 | ; relative to the deployment path 190 | purge[] = "cache/" 191 | ; absolute path 192 | purge[] = "/public_html/wp-content/themes/base/cache/" 193 | ``` 194 | 195 | ## How it works 196 | 197 | PHPloy stores a file called `.revision` on your server. This file contains the hash of the commit that you have deployed to that server. When you run `phploy`, it downloads that file and compares the commit reference in it with the commit you are trying to deploy to find out which files to upload. 198 | 199 | PHPloy also stores a `.revision` file for each submodule in your repository. 200 | 201 | ## Contribute 202 | 203 | If you've got any suggestions, questions, or anything else about PHPloy, [you should create an issue here](https://github.com/banago/PHPloy/issues). 204 | 205 | ## Credits 206 | 207 | The people that have brought PHPloy to you are: 208 | 209 | * [Baki Goxhaj](https://twitter.com/banago) - lead developer 210 | * [Bruno De Barros](https://twitter.com/terraduo) - initial inspiration 211 | * [Fadion Dashi](https://twitter.com/jonidashi) - contributor 212 | * [Simon East](https://twitter.com/SimoEast) - contributor, Windows support 213 | * [Mark Beech](https://github.com/JayBizzle) - contributor 214 | * [Guido Hendriks](https://twitter.com/GuidoHendriks) - contributor 215 | 216 | ## Version history 217 | 218 | Please check [release history](https://github.com/banago/PHPloy/releases) for details. 219 | -------------------------------------------------------------------------------- /src/PHPloy.php: -------------------------------------------------------------------------------- 1 | 7 | * @author Bruno De Barros 8 | * @author Fadion Dashi 9 | * @author Simon East 10 | * @author Mark Beech 11 | * @author Guido Hendriks 12 | * @author Travis Hyyppä 13 | * @link https://github.com/banago/PHPloy 14 | * @license MIT License 15 | * @version 3.5.6 16 | */ 17 | 18 | namespace Banago\PHPloy; 19 | 20 | use Banago\PHPloy\Ansi; 21 | use Banago\Bridge\Bridge; 22 | 23 | /** 24 | * PHPloy Class 25 | */ 26 | class PHPloy 27 | { 28 | /** 29 | * @var string $phployVersion 30 | */ 31 | protected $phployVersion = '3.5.6'; 32 | 33 | /** 34 | * @var string $revision 35 | */ 36 | public $revision; 37 | 38 | /** 39 | * @var string $localRevision 40 | */ 41 | public $localRevision; 42 | 43 | /** 44 | * Keep track of which server we are currently deploying to 45 | * 46 | * @var string $currentlyDeploying 47 | */ 48 | public $currentlyDeploying = ''; 49 | 50 | /** 51 | * A list of files that should NOT be uploaded to the remote server 52 | * 53 | * @var array $filesToIgnore 54 | */ 55 | public $filesToIgnore = array(); 56 | 57 | /** 58 | * A list of files that should NOT be uploaded to any defined server 59 | * 60 | * @var array $globalFilesToIgnore 61 | */ 62 | public $globalFilesToIgnore = array( 63 | '.gitignore', 64 | '.gitmodules', 65 | ); 66 | 67 | /** 68 | * A list of patterns that a file MUST match to be uploaded 69 | * to the remote server 70 | */ 71 | public $filesToInclude = array(); 72 | 73 | /** 74 | * To activate submodule deployment use the --submodules argument 75 | * 76 | * @var bool $scanSubmodules 77 | */ 78 | public $scanSubmodules = false; 79 | 80 | /** 81 | * If you need support for sub-submodules, ensure this is set to TRUE 82 | * Set to false when the --skip-subsubmodules command line option is used 83 | * 84 | * @var bool $scanSubSubmodules 85 | */ 86 | public $scanSubSubmodules = true; 87 | 88 | /** 89 | * @var array $servers 90 | */ 91 | public $servers = array(); 92 | 93 | /** 94 | * @var array $submodules 95 | */ 96 | public $submodules = array(); 97 | 98 | /** 99 | * @var array $purgeDirs 100 | */ 101 | public $purgeDirs = array(); 102 | 103 | /** 104 | * The name of the file on remote servers that stores the current revision hash 105 | * 106 | * @var string $dotRevisionFilename 107 | */ 108 | public $dotRevisionFilename = '.revision'; 109 | 110 | /** 111 | * The filename from which to read remote server details 112 | * 113 | * @var string $deplyIniFilename 114 | */ 115 | public $iniFilename = 'deploy.ini'; 116 | 117 | /** 118 | * List of available "short" command line options, prefixed by a single hyphen 119 | * Colon suffix indicates that the option requires a value 120 | * Double-colon suffix indicates that the option *may* accept a value 121 | * See descriptions below 122 | * 123 | * @var string $shortops 124 | */ 125 | protected $shortopts = 'los:'; 126 | 127 | /** 128 | * List of available "long" command line options, prefixed by double-hyphen 129 | * Colon suffix indicates that the option requires a value 130 | * Double-colon suffix indicates that the option *may* accept a value 131 | * 132 | * --help or -? Displays command line options 133 | * --list or -l Lists the files that *would* be deployed if run without this option 134 | * --rollback Deploys the previous commit/revision 135 | * --rollback="[revision hash]" Deploys the specific commit/revision 136 | * --server="[server name]" Deploys to the server entry listed in deploy.ini 137 | * or -s [server name] 138 | * --sync Updates the remote .revision file with the hash of the current HEAD 139 | * --sync="[revision hash]" Updates the remove .revision file with the provided hash 140 | * --submodules Deploy submodules; turned off by default 141 | * --skip-subsubmodules Skips the scanning of sub-submodules which is currently quite slow 142 | * --repo="[repo path]" Sets an external repo path 143 | * --others Uploads files even if they are excluded in .gitignore 144 | * --debug Displays extra messages including git and FTP commands 145 | * --all Deploys to all configured servers (unless one was specified in the command line) 146 | * --init Creates sample deploy.ini file 147 | * 148 | * @var array $longopts 149 | */ 150 | protected $longopts = array('no-colors', 'help', 'list', 'rollback::', 'server:', 'sync::', 'submodules', 'skip-subsubmodules', 'others', 'repo:', 'debug', 'version', 'all', 'init'); 151 | 152 | /** 153 | * @var bool|resource $connection 154 | */ 155 | protected $connection = false; 156 | 157 | /** 158 | * @var string $server 159 | */ 160 | protected $server = ''; 161 | 162 | /** 163 | * @var string $repo 164 | */ 165 | protected $repo; 166 | 167 | /** 168 | * @var string $mainRepo 169 | */ 170 | protected $mainRepo; 171 | 172 | /** 173 | * @var bool|string $currentSubmoduleName 174 | */ 175 | protected $currentSubmoduleName = false; 176 | 177 | /** 178 | * Holds the path to the .revision file 179 | * For the main repository this will be the value of $dotRevisionFilename ('.revision' by default) 180 | * but for submodules, the submodule path will be prepended 181 | * 182 | * @var string $dotRevision 183 | */ 184 | protected $dotRevision; 185 | 186 | /** 187 | * Whether phploy is running in list mode (--list or -l commands) 188 | * @var bool $listFiles 189 | */ 190 | protected $listFiles = false; 191 | 192 | /** 193 | * Whether the --help command line option was given 194 | * @var bool $displayHelp 195 | */ 196 | protected $displayHelp = false; 197 | 198 | /** 199 | * Whether the --version command line option was given 200 | * @var bool $displayHelp 201 | */ 202 | protected $displayVersion = false; 203 | 204 | /** 205 | * Whether the --sync command line option was given 206 | * @var bool $sync 207 | */ 208 | protected $sync = false; 209 | 210 | /** 211 | * Whether phploy should ignore .gitignore (--others or -o commands) 212 | * @var bool $others 213 | */ 214 | protected $others = false; 215 | 216 | /** 217 | * Whether to print extra debugging info to the console, especially for git & FTP commands 218 | * Activated using --debug command line option 219 | * @var bool $debug 220 | */ 221 | protected $debug = false; 222 | 223 | /** 224 | * Keep track of current deployment size 225 | * @var int $deploymentSize 226 | */ 227 | protected $deploymentSize = 0; 228 | 229 | /** 230 | * Keep track of if a default server has been configured 231 | * @var bool $defaultServer 232 | */ 233 | protected $defaultServer = false; 234 | 235 | /** 236 | * Weather the --all command line option was given 237 | * @var bool deployAll 238 | */ 239 | protected $deployAll = false; 240 | 241 | /** 242 | * Whether the --init command line option was given 243 | * @var bool init 244 | */ 245 | protected $init = false; 246 | 247 | /** 248 | * Constructor 249 | */ 250 | public function __construct() 251 | { 252 | $this->parseOptions(); 253 | 254 | $this->output("\r\n---------------------------------------------------"); 255 | $this->output("| PHPloy v{$this->phployVersion} |"); 256 | $this->output("---------------------------------------------------\r\n"); 257 | 258 | if ($this->displayHelp) { 259 | $this->displayHelp(); 260 | return; 261 | } 262 | 263 | if ($this->displayVersion) { 264 | return; 265 | } 266 | 267 | if ($this->init) { 268 | $this->createSampleIniFile(); 269 | return; 270 | } 271 | 272 | if (file_exists("$this->repo/.git")) { 273 | if ($this->listFiles) { 274 | $this->output("PHPloy is running in LIST mode. No remote files will be modified.\r\n"); 275 | } 276 | 277 | $this->checkSubmodules($this->repo); 278 | 279 | $this->deploy($this->revision); 280 | } else { 281 | throw new \Exception("'{$this->repo}' is not a Git repository."); 282 | } 283 | } 284 | 285 | /** 286 | * Get current revision 287 | * 288 | * @return string with current revision hash 289 | */ 290 | private function currentRevision() 291 | { 292 | $currentRevision = $this->gitCommand('rev-parse HEAD'); 293 | return $currentRevision[0]; 294 | } 295 | 296 | /** 297 | * Displays the various command line options 298 | * 299 | * @return null 300 | */ 301 | public function displayHelp() 302 | { 303 | // $this->output(); 304 | $readMe = __DIR__ . '/readme.md'; 305 | if (file_exists($readMe)) { 306 | $this->output(file_get_contents($readMe)); 307 | } 308 | } 309 | 310 | /** 311 | * Creates sample ini file 312 | * 313 | * @return null 314 | */ 315 | private function createSampleIniFile() 316 | { 317 | $sampleIniFile = __DIR__ . '/sample.ini'; 318 | if (file_exists($sampleIniFile)) { 319 | if (copy($sampleIniFile, getcwd() . '/deploy.ini')) { 320 | $this->output('Sample deploy.ini file created.'); 321 | } 322 | } 323 | } 324 | 325 | /** 326 | * Parse CLI options 327 | * For descriptions of the various options, see the comments for $this->longopts 328 | * 329 | * @return null 330 | */ 331 | public function parseOptions() 332 | { 333 | $options = getopt($this->shortopts, $this->longopts); 334 | 335 | if (isset($options['no-colors'])) { 336 | Ansi::$enabled = false; 337 | } 338 | 339 | // -? command is not correctly parsed by getopt() (at least on Windows) 340 | // so need to check $argv variable instead 341 | global $argv; 342 | if (in_array('-?', $argv) or isset($options['help'])) { 343 | $this->displayHelp = true; 344 | } 345 | 346 | if (isset($options['debug'])) { 347 | $this->debug = true; 348 | } 349 | 350 | if (isset($options['version'])) { 351 | $this->displayVersion = true; 352 | } 353 | 354 | if (isset($options['l']) or isset($options['list'])) { 355 | $this->listFiles = true; 356 | } 357 | 358 | if (isset($options['s']) or isset($options['server'])) { 359 | $this->server = isset($options['s']) ? $options['s'] : $options['server']; 360 | } 361 | 362 | if (isset($options['o']) or isset($options['others'])) { 363 | $this->others = true; 364 | } 365 | 366 | if (isset($options['sync'])) { 367 | $this->sync = empty($options['sync']) ? 'sync' : $options['sync']; 368 | } 369 | 370 | if (isset($options['rollback'])) { 371 | $this->revision = ($options['rollback'] == '') ? 'HEAD^' : $options['rollback']; 372 | } else { 373 | $this->revision = 'HEAD'; 374 | } 375 | 376 | if (isset($options['submodules'])) { 377 | $this->scanSubmodules = true; 378 | } 379 | 380 | if (isset($options['skip-subsubmodules'])) { 381 | $this->scanSubSubmodules = false; 382 | } 383 | 384 | if (isset($options['all'])) { 385 | $this->deployAll = true; 386 | } 387 | 388 | if (isset($options['init'])) { 389 | $this->init = true; 390 | } 391 | 392 | $this->repo = isset($options['repo']) ? rtrim($options['repo'], '/') : getcwd(); 393 | $this->mainRepo = $this->repo; 394 | 395 | $this->debug('Command line options detected: ' . print_r($options, true)); 396 | 397 | } 398 | 399 | /** 400 | * Check for submodules 401 | * 402 | * @param string $repo 403 | * @return null 404 | */ 405 | public function checkSubmodules($repo) 406 | { 407 | if ($this->scanSubmodules) { 408 | $this->output('Scanning repository...'); 409 | } 410 | 411 | $output = $this->gitCommand('submodule status', $repo); 412 | 413 | if ($this->scanSubmodules) { 414 | $this->output(' Found ' . count($output) . ' submodules.'); 415 | } 416 | 417 | if (count($output) > 0) { 418 | foreach ($output as $line) { 419 | $line = explode(' ', trim($line)); 420 | 421 | // If submodules are turned off, don't add them to queue 422 | if ($this->scanSubmodules) { 423 | $this->submodules[] = array('revision' => $line[0], 'name' => $line[1], 'path' => $repo.'/'.$line[1]); 424 | $this->output(sprintf( 425 | ' Found submodule %s. %s', 426 | $line[1], 427 | $this->scanSubSubmodules ? PHP_EOL . ' Scanning for sub-submodules...' : null 428 | )); 429 | } 430 | 431 | $this->globalFilesToIgnore[] = $line[1]; 432 | 433 | $this->checkSubSubmodules($repo, $line[1]); 434 | } 435 | if (! $this->scanSubSubmodules) { 436 | $this->output(' Skipping search for sub-submodules.'); 437 | } 438 | } 439 | } 440 | 441 | /** 442 | * Check for sub-submodules 443 | * 444 | * @todo This function is quite slow (at least on Windows it often takes several seconds for each call). 445 | * Can it be optimized? 446 | * It appears that this is called for EACH submodule, but then also does another `git submodule foreach` 447 | * @param string $repo 448 | * @param string $name 449 | * @return null 450 | */ 451 | public function checkSubSubmodules($repo, $name) 452 | { 453 | $output = $this->gitCommand('submodule foreach git submodule status', $repo); 454 | 455 | if (count($output) > 0) { 456 | foreach ($output as $line) { 457 | $line = explode(' ', trim($line)); 458 | 459 | // Skip if string start with 'Entering' 460 | if (trim($line[0]) == 'Entering') { 461 | continue; 462 | } 463 | 464 | // If sub-submodules are turned off, don't add them to queue 465 | if ($this->scanSubmodules && $this->scanSubSubmodules) { 466 | $this->submodules[] = array( 467 | 'revision' => $line[0], 468 | 'name' => $name.'/'.$line[1], 469 | 'path' => $repo.'/'.$name.'/'.$line[1] 470 | ); 471 | $this->output(sprintf(' Found sub-submodule %s.', "$name/$line[1]")); 472 | } 473 | 474 | // But ignore them nonetheless 475 | $this->globalFilesToIgnore[] = $line[1]; 476 | } 477 | } 478 | } 479 | 480 | /** 481 | * Parse Credentials 482 | * 483 | * @param string $deploy The filename to obtain the list of servers from, normally $this->iniFilename 484 | * @return array of servers listed in the file $deploy 485 | */ 486 | public function parseCredentials($deploy) 487 | { 488 | if (! file_exists($deploy)) { 489 | throw new \Exception("'$deploy' does not exist."); 490 | } else { 491 | $servers = parse_ini_file($deploy, true); 492 | 493 | if (! $servers) { 494 | throw new \Exception("'$deploy' is not a valid .ini file."); 495 | } else { 496 | return $servers; 497 | } 498 | } 499 | } 500 | 501 | /** 502 | * Reads the deploy.ini file and populates the $this->servers array 503 | * 504 | * @return null 505 | */ 506 | public function prepareServers() 507 | { 508 | $defaults = array( 509 | 'scheme' => 'ftp', 510 | 'host' => '', 511 | 'user' => '', 512 | 'pass' => '', 513 | 'pubkey' => '', 514 | 'privkey' => '', 515 | 'keypass' => '', 516 | 'port' => '', 517 | 'path' => '/', 518 | 'passive' => true, 519 | 'skip' => array(), 520 | 'purge' => array() 521 | ); 522 | 523 | $ini = $this->repo . DIRECTORY_SEPARATOR . $this->iniFilename; 524 | 525 | $servers = $this->parseCredentials($ini); 526 | 527 | foreach ($servers as $name => $options) { 528 | $options = array_merge($defaults, $options); 529 | 530 | // Determine if a default server is configured 531 | if ($name == 'default') { 532 | $this->defaultServer = true; 533 | } 534 | 535 | // Re-merge parsed url in quickmode 536 | if (isset($options['quickmode'])) { 537 | $options = array_merge($options, parse_url($options['quickmode'])); 538 | } 539 | 540 | // Ignoring for the win 541 | $this->filesToIgnore[$name] = $this->globalFilesToIgnore; 542 | $this->filesToIgnore[$name][] = $this->iniFilename; 543 | 544 | if (! empty($servers[$name]['skip'])) { 545 | $this->filesToIgnore[$name] = array_merge($this->filesToIgnore[$name], $servers[$name]['skip']); 546 | } 547 | 548 | if (! empty($servers[$name]['include'])) { 549 | $this->filesToInclude[$name] = $servers[$name]['include']; 550 | } else { 551 | $this->filesToInclude[$name] = array('*'); 552 | } 553 | 554 | if (! empty($servers[$name]['purge'])) { 555 | $this->purgeDirs[$name] = $servers[$name]['purge']; 556 | } 557 | 558 | // Ask user a password if it is empty, and if a public or private key is not defined 559 | if ($options['pass'] === '' && $options['pubkey'] === '' && $options['privkey'] === '') { 560 | fputs(STDOUT, 'You have not provided a password for user "'. $options['user'] .'". Please enter a password: '); 561 | $input = urlencode($this->getPassword()); 562 | 563 | if ($input == '') { 564 | $this->output("\r\nYou entered an empty password. All good, continuing deployment ..."); 565 | } else { 566 | $options['pass'] = $input; 567 | $this->output("\r\nWe got your password, thanks. Continuing deployment ..."); 568 | } 569 | } 570 | 571 | $bridgeOptions = array(); 572 | 573 | if ($options['pubkey'] !== '' || $options['privkey'] !== '') { 574 | $key = array( 575 | 'pubkeyfile' => false, 576 | 'privkeyfile' => false, 577 | 'user' => $options['user'], 578 | 'passphrase' => false 579 | ); 580 | 581 | if ($options['pubkey'] == '' || !is_readable($options['pubkey'])) { 582 | throw new \Exception("Cannot read SSH public key file: {$options['pubkey']}"); 583 | } 584 | $key['pubkeyfile'] = $options['pubkey']; 585 | 586 | if ($options['privkey'] == '' || !is_readable($options['privkey'])) { 587 | throw new \Exception("Cannot read SSH private key file: {$options['pubkey']}"); 588 | } 589 | $key['privkeyfile'] = $options['privkey']; 590 | 591 | if ($options['keypass'] !== '') { 592 | $key['passphrase'] = $options['keypass']; 593 | } 594 | 595 | $bridgeOptions['pubkey'] = $key; 596 | } 597 | 598 | $this->servers[$name] = array( 599 | 'url' => http_build_url('', $options), // Turn options into an URL so that Bridge can work with it. 600 | 'options' => $bridgeOptions 601 | ); 602 | } 603 | } 604 | 605 | /** 606 | * Gets the password from user input, hiding password and replaces it 607 | * with stars (*) if user users Unix / Mac. 608 | * 609 | * @return string the user entered 610 | */ 611 | private function getPassword() 612 | { 613 | if (strtoupper(substr(PHP_OS, 0, 3)) == 'WIN') { 614 | return trim(fgets(STDIN)); 615 | } 616 | 617 | $oldStyle = shell_exec('stty -g'); 618 | $password = ''; 619 | 620 | shell_exec('stty -icanon -echo min 1 time 0'); 621 | while (true) { 622 | $char = fgetc(STDIN); 623 | if ($char === "\n") { 624 | break; 625 | } elseif (ord($char) === 127) { 626 | if (strlen($password) > 0) { 627 | fwrite(STDOUT, "\x08 \x08"); 628 | $password = substr($password, 0, -1); 629 | } 630 | } else { 631 | fwrite(STDOUT, "*"); 632 | $password .= $char; 633 | } 634 | } 635 | 636 | shell_exec('stty ' . $oldStyle); 637 | return $password; 638 | } 639 | 640 | /** 641 | * Executes a console command and returns the output (as an array) 642 | * 643 | * @return array of all lines that were output to the console during the command (STDOUT) 644 | */ 645 | public function runCommand($command) 646 | { 647 | // Escape special chars in string with a backslash 648 | $command = escapeshellcmd($command); 649 | 650 | $this->debug("CONSOLE: $command"); 651 | 652 | exec($command, $output); 653 | 654 | $this->debug('' . implode("\r\n", $output)); 655 | 656 | return $output; 657 | } 658 | 659 | /** 660 | * Runs a git command and returns the output (as an array) 661 | * 662 | * @param string $command "git [your-command-here]" 663 | * @param string $repoPath Defaults to $this->repo 664 | * @return array Lines of the output 665 | */ 666 | public function gitCommand($command, $repoPath = null) 667 | { 668 | if (! $repoPath) { 669 | $repoPath = $this->repo; 670 | } 671 | 672 | $command = 'git -C "' . $repoPath . '" --git-dir="' . $repoPath . '/.git" --work-tree="' . $repoPath . '" ' . $command; 673 | 674 | return $this->runCommand($command); 675 | } 676 | 677 | /** 678 | * Compare revisions and returns array of files to upload: 679 | * 680 | * array( 681 | * 'upload' => $filesToUpload, 682 | * 'delete' => $filesToDelete 683 | * ); 684 | * 685 | * @param string $localRevision 686 | * @return array 687 | * @throws Exception if unknown git diff status 688 | */ 689 | public function compare($localRevision) 690 | { 691 | $remoteRevision = null; 692 | $tmpFile = tmpfile(); 693 | $filesToUpload = array(); 694 | $filesToDelete = array(); 695 | $filesToSkip = array(); 696 | $output = array(); 697 | 698 | if ($this->currentSubmoduleName) { 699 | $this->dotRevision = $this->currentSubmoduleName.'/'.$this->dotRevisionFilename; 700 | } else { 701 | $this->dotRevision = $this->dotRevisionFilename; 702 | } 703 | 704 | // Fetch the .revision file from the server and write it to $tmpFile 705 | $this->debug("Fetching {$this->dotRevision} file"); 706 | 707 | if ($this->connection->exists($this->dotRevision)) { 708 | $remoteRevision = $this->connection->get($this->dotRevision); 709 | } else { 710 | $this->output('|----[ No revision found. Fresh deployment - grab a coffee ]----|'); 711 | } 712 | 713 | // Use git to list the changed files between $remoteRevision and $localRevision 714 | // "-c core.quotepath=false" in command fixes special chars issue like ë, ä or ü in file names 715 | if ($this->others) { 716 | $command = '-c core.quotepath=false ls-files -o'; 717 | } elseif (empty($remoteRevision)) { 718 | $command = '-c core.quotepath=false ls-files'; 719 | } elseif ($localRevision === 'HEAD') { 720 | $command = '-c core.quotepath=false diff --name-status '.$remoteRevision.' '.$localRevision; 721 | } else { 722 | $command = '-c core.quotepath=false diff --name-status '.$remoteRevision.' '.$localRevision; 723 | } 724 | 725 | $output = $this->gitCommand($command); 726 | 727 | /** 728 | * Git Status Codes 729 | * 730 | * A: addition of a file 731 | * C: copy of a file into a new one 732 | * D: deletion of a file 733 | * M: modification of the contents or mode of a file 734 | * R: renaming of a file 735 | * T: change in the type of the file 736 | * U: file is unmerged (you must complete the merge before it can be committed) 737 | * X: "unknown" change type (most probably a bug, please report it) 738 | */ 739 | 740 | if (! empty($remoteRevision) && !$this->others) { 741 | foreach ($output as $line) { 742 | if ($line[0] === 'A' or $line[0] === 'C' or $line[0] === 'M' or $line[0] === 'T') { 743 | $filesToUpload[] = trim(substr($line, 1)); 744 | } elseif ($line[0] == 'D' or $line[0] === 'T') { 745 | $filesToDelete[] = trim(substr($line, 1)); 746 | } else { 747 | throw new \Exception("Unsupported git-diff status: {$line[0]}"); 748 | } 749 | } 750 | } else { 751 | $filesToUpload = $output; 752 | } 753 | 754 | $filteredFilesToUpload = $this->filterIgnoredFiles($filesToUpload); 755 | $filteredFilesToDelete = $this->filterIgnoredFiles($filesToDelete); 756 | 757 | $filesToUpload = $filteredFilesToUpload['files']; 758 | $filesToDelete = $filteredFilesToDelete['files']; 759 | 760 | $filesToSkip = array_merge($filteredFilesToUpload['filesToSkip'], $filteredFilesToDelete['filesToSkip']); 761 | 762 | return array( 763 | $this->currentlyDeploying => array( 764 | 'delete' => $filesToDelete, 765 | 'upload' => $filesToUpload, 766 | 'skip' => $filesToSkip, 767 | ) 768 | ); 769 | } 770 | 771 | /** 772 | * Filter ignore files 773 | * 774 | * @param array $files Array of files which needed to be filtered 775 | * @return Array with `files` (filtered) and `filesToSkip` 776 | */ 777 | private function filterIgnoredFiles($files) 778 | { 779 | $filesToSkip = array(); 780 | 781 | foreach ($files as $i => $file) { 782 | $matched = false; 783 | foreach ($this->filesToInclude[$this->currentlyDeploying] as $pattern) { 784 | if ($this->patternMatch($pattern, $file)) { 785 | $matched = true; 786 | break; 787 | } 788 | } 789 | if (! $matched) { 790 | unset($files[$i]); 791 | $filesToSkip[] = $file; 792 | continue; 793 | } 794 | 795 | foreach ($this->filesToIgnore[$this->currentlyDeploying] as $pattern) { 796 | if ($this->patternMatch($pattern, $file)) { 797 | unset($files[$i]); 798 | $filesToSkip[] = $file; 799 | break; 800 | } 801 | } 802 | } 803 | 804 | $files = array_values($files); 805 | 806 | return array( 807 | 'files' => $files, 808 | 'filesToSkip' => $filesToSkip 809 | ); 810 | } 811 | 812 | /** 813 | * Deploy (or list) changed files 814 | * 815 | * @param string $revision 816 | */ 817 | public function deploy($revision = 'HEAD') 818 | { 819 | $this->prepareServers(); 820 | 821 | // Exit with an error if the specified server does not exist in deploy.ini 822 | if ($this->server != '' && !array_key_exists($this->server, $this->servers)) { 823 | throw new \Exception("The server \"{$this->server}\" is not defined in {$this->iniFilename}."); 824 | } 825 | 826 | // Loop through all the servers in deploy.ini 827 | foreach ($this->servers as $name => $server) { 828 | $this->currentlyDeploying = $name; 829 | 830 | // Deploys to ALL servers by default 831 | // If a server is specified, we skip all servers that don't match the one specified 832 | if ($this->server != '' && $this->server != $name) { 833 | continue; 834 | } 835 | 836 | // If no server was specified in the command line but a default server 837 | // configuration exists, we'll use that (as long as --all was not specified) 838 | elseif ($this->server == '' && $this->defaultServer == true && $name != 'default' && $this->deployAll == false) { 839 | continue; 840 | } 841 | 842 | $this->connect($server); 843 | 844 | if ($this->sync) { 845 | $this->dotRevision = $this->dotRevisionFilename; 846 | $this->setRevision(); 847 | continue; 848 | } 849 | 850 | $files = $this->compare($revision); 851 | 852 | $this->connect($server); 853 | 854 | $this->output("\r\nSERVER: ".$name); 855 | if ($this->listFiles === true) { 856 | $this->listFiles($files[$this->currentlyDeploying]); 857 | } else { 858 | $this->push($files[$this->currentlyDeploying]); 859 | // Purge 860 | if (isset($this->purgeDirs[$name]) && count($this->purgeDirs[$name]) > 0) { 861 | $this->purge($this->purgeDirs[$name]); 862 | } 863 | } 864 | 865 | if ($this->scanSubmodules && count($this->submodules) > 0) { 866 | foreach ($this->submodules as $submodule) { 867 | $this->repo = $submodule['path']; 868 | $this->currentSubmoduleName = $submodule['name']; 869 | 870 | $this->output("\r\nSUBMODULE: ".$this->currentSubmoduleName); 871 | 872 | $files = $this->compare($revision); 873 | 874 | if ($this->listFiles === true) { 875 | $this->listFiles($files[$this->currentlyDeploying]); 876 | } else { 877 | $this->push($files[$this->currentlyDeploying]); 878 | } 879 | } 880 | // We've finished deploying submodules, reset settings for the next server 881 | $this->repo = $this->mainRepo; 882 | $this->currentSubmoduleName = false; 883 | } 884 | 885 | // Done 886 | if (! $this->listFiles) { 887 | $this->output("\r\n|----------------[ ".$this->humanFilesize($this->deploymentSize)." Deployed ]----------------|"); 888 | $this->deploymentSize = 0; 889 | } 890 | } 891 | } 892 | 893 | /** 894 | * Return a human readable filesize 895 | * 896 | * @param int $bytes 897 | * @param int $decimals 898 | */ 899 | public function humanFilesize($bytes, $decimals = 2) 900 | { 901 | $sz = 'BKMGTP'; 902 | $factor = floor((strlen($bytes) - 1) / 3); 903 | return sprintf("%.{$decimals}f", $bytes / pow(1024, $factor)) . @$sz[$factor]; 904 | } 905 | 906 | /** 907 | * Glob the file path 908 | * 909 | * @param string $pattern 910 | * @param string $string 911 | */ 912 | public function patternMatch($pattern, $string) 913 | { 914 | return preg_match("#^".strtr(preg_quote($pattern, '#'), array('\*' => '.*', '\?' => '.'))."$#i", $string); 915 | } 916 | 917 | /** 918 | * Check what files will be uploaded/deleted 919 | * 920 | * @param array $files 921 | */ 922 | public function listFiles($files) 923 | { 924 | if (count($files['upload']) == 0 && count($files['delete']) == 0) { 925 | $this->output(" No files to upload."); 926 | } 927 | 928 | if (count($files['delete']) > 0) { 929 | $this->output(" Files that will be deleted in next deployment:"); 930 | 931 | foreach ($files['delete'] as $file_to_delete) { 932 | $this->output(" ".$file_to_delete); 933 | } 934 | } 935 | 936 | if (count($files['upload']) > 0) { 937 | $this->output(" Files that will be uploaded in next deployment:"); 938 | 939 | foreach ($files['upload'] as $file_to_upload) { 940 | $this->output(" ".$file_to_upload); 941 | } 942 | } 943 | } 944 | 945 | /** 946 | * Connect to the Server 947 | * 948 | * @param string $server 949 | * @throws Exception if it can't connect to FTP server 950 | */ 951 | public function connect($server) 952 | { 953 | try { 954 | $connection = new Bridge($server['url'], $server['options']); 955 | $this->connection = $connection; 956 | } catch (\Exception $e) { 957 | echo Ansi::tagsToColors("\r\nOh Snap: {$e->getMessage()}\r\n"); 958 | // If we could not connect, what's the point of existing 959 | die(); 960 | } 961 | } 962 | 963 | /** 964 | * Update the current remote server with the array of files provided 965 | * 966 | * @param array $files 2-dimensional array with 2 indices: 'upload' and 'delete' 967 | * Each of these contains an array of filenames and paths (relative to repository root) 968 | */ 969 | public function push($files) 970 | { 971 | // We will write this in the server 972 | $this->localRevision = $this->currentRevision(); 973 | 974 | $initialBranch = $this->currentBranch(); 975 | 976 | // If revision is not HEAD, the current one, it means this is a rollback. 977 | // So, we have to revert the files the the state they were in that revision. 978 | if ($this->revision != 'HEAD') { 979 | $this->output(" Rolling back working copy"); 980 | 981 | // BUG: This does NOT work correctly for submodules & subsubmodules (and leaves them in an incorrect state) 982 | // It technically should do a submodule update in the parent, not a checkout inside the submodule 983 | $this->gitCommand('checkout '.$this->revision); 984 | } 985 | 986 | $filesToDelete = $files['delete']; 987 | // Add deleted directories to the list of files to delete. Git does not handle this. 988 | $dirsToDelete = []; 989 | if (count($filesToDelete) > 0) { 990 | $dirsToDelete = $this->hasDeletedDirectories($filesToDelete); 991 | } 992 | $filesToUpload = $files['upload']; 993 | 994 | // Not needed any longer 995 | unset($files); 996 | 997 | // Delete files 998 | if (count($filesToDelete) > 0) { 999 | foreach ($filesToDelete as $fileNo => $file) { 1000 | if ($this->currentSubmoduleName) { 1001 | $file = $this->currentSubmoduleName.'/'.$file; 1002 | } 1003 | $numberOfFilesToDelete = count($filesToDelete); 1004 | $fileNo = str_pad(++$fileNo, strlen($numberOfFilesToDelete), ' ', STR_PAD_LEFT); 1005 | if ($this->connection->exists($file)) { 1006 | $this->connection->rm($file); 1007 | $this->output(" × $fileNo of $numberOfFilesToDelete {$file}"); 1008 | } else { 1009 | $this->output(" ! $fileNo of $numberOfFilesToDelete {$file} not found"); 1010 | } 1011 | } 1012 | } 1013 | 1014 | // Delete Directories 1015 | if (count($dirsToDelete) > 0) { 1016 | foreach ($dirsToDelete as $dirNo => $dir) { 1017 | if ($this->currentSubmoduleName) { 1018 | $dir = $this->currentSubmoduleName.'/'.$dir; 1019 | } 1020 | $numberOfdirsToDelete = count($dirsToDelete); 1021 | $dirNo = str_pad(++$dirNo, strlen($numberOfdirsToDelete), ' ', STR_PAD_LEFT); 1022 | if ($this->connection->exists($dir)) { 1023 | $this->connection->rmdir($dir); 1024 | $this->output(" × $dirNo of $numberOfdirsToDelete {$dir}"); 1025 | } else { 1026 | $this->output(" ! $dirNo of $numberOfdirsToDelete {$dir} not found"); 1027 | } 1028 | } 1029 | } 1030 | 1031 | // Upload Files 1032 | if (count($filesToUpload) > 0) { 1033 | foreach ($filesToUpload as $fileNo => $file) { 1034 | if ($this->currentSubmoduleName) { 1035 | $file = $this->currentSubmoduleName.'/'.$file; 1036 | } 1037 | 1038 | // Make sure the folder exists in the FTP server. 1039 | $dir = explode("/", dirname($file)); 1040 | $path = ""; 1041 | $ret = true; 1042 | 1043 | // Skip mkdir if dir is basedir 1044 | if ($dir[0] !== '.') { 1045 | // Loop through each folder in the path /a/b/c/d.txt to ensure that it exists 1046 | for ($i = 0, $count = count($dir); $i < $count; $i++) { 1047 | $path .= $dir[$i].'/'; 1048 | 1049 | if (! isset($pathsThatExist[$path])) { 1050 | $origin = $this->connection->pwd(); 1051 | 1052 | if (! $this->connection->exists($path)) { 1053 | $this->connection->mkdir($path); 1054 | $this->output("Created directory '$path'."); 1055 | $pathsThatExist[$path] = true; 1056 | } else { 1057 | $this->connection->cd($path); 1058 | $pathsThatExist[$path] = true; 1059 | } 1060 | 1061 | // Go home 1062 | $this->connection->cd($origin); 1063 | } 1064 | } 1065 | } 1066 | 1067 | // Now upload the file, attempting 10 times 1068 | // before exiting with a failure message 1069 | $uploaded = false; 1070 | $attempts = 1; 1071 | while (! $uploaded) { 1072 | if ($attempts == 10) { 1073 | throw new \Exception("Tried to upload $file 10 times and failed. Something is wrong..."); 1074 | } 1075 | 1076 | $data = file_get_contents($this->repo . '/' . ($this->currentSubmoduleName ? str_replace($this->currentSubmoduleName.'/', "", $file) : $file)); 1077 | $remoteFile = $file; 1078 | $uploaded = $this->connection->put($data, $remoteFile); 1079 | 1080 | if (! $uploaded) { 1081 | $attempts = $attempts + 1; 1082 | $this->output("Failed to upload {$file}. Retrying (attempt $attempts/10)..."); 1083 | } else { 1084 | $this->deploymentSize += filesize($this->repo . '/' . ($this->currentSubmoduleName ? str_replace($this->currentSubmoduleName.'/', "", $file) : $file)); 1085 | } 1086 | } 1087 | 1088 | $numberOfFilesToUpdate = count($filesToUpload); 1089 | 1090 | $fileNo = str_pad(++$fileNo, strlen($numberOfFilesToUpdate), ' ', STR_PAD_LEFT); 1091 | $this->output(" ^ $fileNo of $numberOfFilesToUpdate {$file}"); 1092 | } 1093 | } 1094 | 1095 | if (count($filesToUpload) > 0 or count($filesToDelete) > 0) { 1096 | // Set revision on server 1097 | $this->setRevision(); 1098 | } else { 1099 | $this->output(" No files to upload or delete."); 1100 | } 1101 | 1102 | // If $this->revision is not HEAD, it means the rollback command was provided 1103 | // The working copy was rolled back earlier to run the deployment, and we now want to return the working copy 1104 | // back to its original state 1105 | if ($this->revision != 'HEAD') { 1106 | $this->gitCommand('checkout '.($initialBranch ?: 'master')); 1107 | } 1108 | } 1109 | 1110 | /** 1111 | * Gets the current branch name. 1112 | * 1113 | * @return string - current branch name or false if not in branch 1114 | */ 1115 | private function currentBranch() 1116 | { 1117 | $currentBranch = $this->gitCommand('rev-parse --abbrev-ref HEAD')[0]; 1118 | if ($currentBranch != 'HEAD') { 1119 | return $currentBranch; 1120 | } 1121 | return false; 1122 | } 1123 | 1124 | /** 1125 | * Sets version hash on the server. 1126 | */ 1127 | public function setRevision() 1128 | { 1129 | // By default we update the revision file to the local revision, 1130 | // unless the sync command was called with a specific revision 1131 | $localRevision = $this->currentRevision(); 1132 | if ($this->sync && $this->sync != 'sync') { 1133 | $localRevision = $this->sync; 1134 | } 1135 | $consoleMessage = "Updating remote revision file to ".$localRevision; 1136 | 1137 | if ($this->sync) { 1138 | $this->output("\r\nSYNC: $consoleMessage"); 1139 | } else { 1140 | $this->debug($consoleMessage); 1141 | } 1142 | 1143 | try { 1144 | $this->connection->put($localRevision, $this->dotRevision); 1145 | } catch (\Exception $e) { 1146 | throw new \Exception("Could not update the revision file on server: $e->getMessage()"); 1147 | } 1148 | } 1149 | 1150 | /** 1151 | * Purge given directory's contents 1152 | * 1153 | * @var string $purgeDirs 1154 | */ 1155 | public function purge($purgeDirs) 1156 | { 1157 | foreach ($purgeDirs as $dir) { 1158 | $origin = $this->connection->pwd(); 1159 | 1160 | $this->output("Purging directory {$dir}"); 1161 | 1162 | // Failing to enter into the directory means should stop 1163 | // the script form purging. Otherwise wrong content is deleted. 1164 | // @Agnis-LV lost ~8GB of important data because of this. Sorry man! 1165 | if (! $this->connection->cd($dir)) { 1166 | $this->output(" ! Could not enter into '{$dir}'. Check your directory path."); 1167 | $this->connection->cd($origin); 1168 | continue; 1169 | } 1170 | 1171 | if (! $tmpFiles = $this->connection->ls()) { 1172 | $this->output(" - Nothing to purge in {$dir}"); 1173 | $this->connection->cd($origin); 1174 | continue; 1175 | } 1176 | 1177 | $haveFiles = false; 1178 | $innerDirs = array(); 1179 | foreach ($tmpFiles as $file) { 1180 | $curr = $this->connection->pwd(); 1181 | if ($this->connection->cd($file)) { 1182 | $innerDirs[] = $file; 1183 | $this->connection->cd($curr); 1184 | } else { 1185 | $haveFiles = true; 1186 | $this->output(" - {$file} is removed from directory"); 1187 | $this->connection->rm($file); 1188 | } 1189 | } 1190 | 1191 | if (! $haveFiles) { 1192 | $this->output(" - Nothing to purge in {$dir}"); 1193 | } else { 1194 | $this->output("Purged {$dir}"); 1195 | } 1196 | 1197 | if (count($innerDirs) > 0) { 1198 | // Recursive purging 1199 | $this->purge($innerDirs); 1200 | } 1201 | 1202 | $this->connection->cd($origin); 1203 | } 1204 | } 1205 | 1206 | /** 1207 | * Checks for deleted directories. Git cares only about files. 1208 | * 1209 | * @param array $filesToDelete 1210 | */ 1211 | public function hasDeletedDirectories($filesToDelete) 1212 | { 1213 | $dirsToDelete = []; 1214 | foreach ($filesToDelete as $file) { 1215 | // Break directories into a list of items 1216 | $parts = explode("/", $file); 1217 | // Remove files name from the list 1218 | array_pop($parts); 1219 | 1220 | foreach ($parts as $i => $part) { 1221 | $prefix = ''; 1222 | // Add the parent directories to directory name 1223 | for ($x = 0; $x < $i; $x++) { 1224 | $prefix .= $parts[$x] . '/'; 1225 | } 1226 | 1227 | $part = $prefix . $part; 1228 | 1229 | // If directory doesn't exist, add to files to delete 1230 | // Relative path won't work consistently, thus getcwd(). 1231 | if (! is_dir(getcwd() . '/' . $part)) { 1232 | $dirsToDelete[] = $part; 1233 | } 1234 | } 1235 | } 1236 | 1237 | // Remove duplicates 1238 | $dirsToDeleteUnique = array_unique($dirsToDelete); 1239 | 1240 | // Reverse order to delete inner children before parents 1241 | $dirsToDeleteOrder = array_reverse($dirsToDeleteUnique); 1242 | 1243 | $this->debug('Directories to be deleted: ' . print_r($dirsToDeleteOrder, true)); 1244 | 1245 | return $dirsToDeleteOrder; 1246 | } 1247 | 1248 | /** 1249 | * Helper method to display messages on the screen. 1250 | * 1251 | * @param string $message 1252 | */ 1253 | public function output($message) 1254 | { 1255 | echo Ansi::tagsToColors($message) . "\r\n"; 1256 | } 1257 | 1258 | /** 1259 | * Helper method to output messages to the console (only in debug mode) 1260 | * Debug mode is activated by setting $this->debug = true or using the command line option --debug 1261 | * 1262 | * @param string $message Message to display on the console 1263 | */ 1264 | public function debug($message) 1265 | { 1266 | if ($this->debug) { 1267 | $this->output("$message"); 1268 | } 1269 | } 1270 | } 1271 | --------------------------------------------------------------------------------