diff --git a/README.md b/README.md index 9c8458c..a119134 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ Or from vim, to insert the output into the commit message, type `:r!composer-loc - `--no-links`: Don't include Compare links in plain text or any links in markdown - `--only-prod`: Only include changes from `packages` - `--only-dev`: Only include changes from `packages-dev` +- `--vcs`: Force vcs (git, svn, ...). Default auto-detect from path ^ File includes anything available as a [protocol stream wrapper](http://php.net/manual/en/wrappers.php) such as URLs. @@ -204,4 +205,5 @@ Thanks to everyone who has shared ideas and code! In particular, - https://github.com/ihor-sviziev - https://github.com/wiese - https://github.com/jibran +- https://github.com/soleuu diff --git a/composer-lock-diff b/composer-lock-diff index 1624db9..21dd9b1 100755 --- a/composer-lock-diff +++ b/composer-lock-diff @@ -4,13 +4,15 @@ $opts = parseOpts(); $changes = array(); +$data_from = load($opts['from'], $opts['path'], $opts['vcs'], ''); +$data_to = load($opts['to'], $opts['path'], $opts['vcs'], 'composer.lock'); if (! $opts['only-dev']) { - $changes['changes'] = diff('packages', $opts['from'], $opts['to'], $opts['path']); + $changes['changes'] = diff('packages', $data_from, $data_to); } if (! $opts['only-prod']) { - $changes['changes-dev'] = diff('packages-dev', $opts['from'], $opts['to'], $opts['path']); + $changes['changes-dev'] = diff('packages-dev', $data_from, $data_to); } if ($opts['json']) { @@ -31,28 +33,24 @@ if ($opts['md']) { )); } -$table_titles = [ +$table_titles = array( 'changes' => 'Production Changes', 'changes-dev' => 'Dev Changes', -]; +); foreach($changes as $k => $diff) { print tableize($table_titles[$k], $diff, $table_opts); } -function diff($key, $from, $to, $base_path) { +function diff($key, $data_from, $data_to) { $pkgs = array(); - $data = load($from, $base_path); - - foreach($data->$key as $pkg) { + foreach($data_from->$key as $pkg) { $pkgs[$pkg->name] = array(version($pkg), 'REMOVED', ''); } - $data = load($to, $base_path); - - foreach($data->$key as $pkg) { + foreach($data_to->$key as $pkg) { if (! array_key_exists($pkg->name, $pkgs)) { $pkgs[$pkg->name] = array('NEW', version($pkg), ''); continue; @@ -150,46 +148,72 @@ function urlFormatterMd($url, $text) { return sprintf('[%s](%s)', $text, $url); } -function load($fileish, $base_path = '') { - $orig = $fileish; - - if (empty($base_path)) { - $base_path = '.' . DIRECTORY_SEPARATOR; - } else { - $base_path = rtrim($base_path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; +// $fileish is what the user actually requested. +// $default_fileish is what it should be if $fileish is empty +function load($fileish, $base_path, $force_vcs, $default_fileish) { + $loaders = ($force_vcs || (empty($fileish) && empty($default_fileish))) ? array() : array('loadFile'); + + $vcses = $force_vcs ? array($force_vcs) : getVcses(); + $errors = array(); + + foreach($vcses as $vcs) { + $detector = 'vcsDetect' . ucfirst($vcs); + if($vcs != $force_vcs && function_exists($detector)) { + list($available, $err) = call_user_func($detector, $fileish, $base_path, $default_fileish); + if ($err) { + $errors[] = $err; + continue; + } + if (!$available) continue; + } + $loaders[] = 'vcsLoad' . ucfirst($vcs); } - if (empty($fileish)) { - $fileish = $base_path . 'composer.lock'; + if (empty($loaders)) { + error_log(implode("\n", $errors)); + if ($force_vcs) { + error_log("Requested vcs '$force_vcs' not installed or otherwise unavailable"); + } else { + error_log("No loaders were found; perhaps your vcs cli tools are not installed, not in PATH, or otherwise unavailable"); + } + exit(1); } - if (isUrl($fileish)) { - if (! in_array(parse_url($fileish, PHP_URL_SCHEME), stream_get_wrappers())) { - error_log("Error: no stream wrapper to open '$fileish'"); - exit(1); + $errors = array(); + foreach($loaders as $loader) { + list($result, $err) = call_user_func_array($loader, array($fileish, $base_path, $default_fileish)); + if (empty($err)) { + return $result; } - - return mustDecodeJson(file_get_contents($fileish), $fileish); + $errors[] = "Failed to find '$fileish' with '$loader'; $err"; } - if (file_exists($fileish)) { - return mustDecodeJson(file_get_contents($fileish), $fileish); + foreach($errors as $e) { + error_log($e); } - if (strpos($orig, ':') === false) { - $fileish .= ':' . $base_path . 'composer.lock'; - } + exit(1); +} - $lines = array(); +function loadFile($fileish, $base_path, $default_fileish) { + if (empty($fileish)) { + $fileish = $default_fileish; + if (!empty($base_path)) { + $fileish = joinPath($base_path, $fileish); + } + } - exec('git show '. escapeshellarg($fileish), $lines, $exit); + // Does it look like a url that we can handle with stream wrappers? + if (isUrl($fileish) && in_array(parse_url($fileish, PHP_URL_SCHEME), stream_get_wrappers())) { + return array(mustDecodeJson(file_get_contents($fileish), $fileish), false); + } - if ($exit !== 0) { - error_log("Error: cannot open $orig or find it in git as $fileish"); - exit(1); + // Is it a file in the local filesystem? + if (file_exists($fileish)) { + return array(mustDecodeJson(file_get_contents($fileish), $fileish), false); } - return mustDecodeJson(implode("\n", $lines), $fileish); + return array(false, "Candidate '$fileish' does not look loadable from the fs or php stream wrappers"); } function isUrl($string) { @@ -278,8 +302,126 @@ function formatCompareDrupal($url, $from, $to) { return sprintf('%s/compare/8.x-%s...8.x-%s', $url, substr(urlencode($from), 0, -2), substr(urlencode($to), 0, -2)); } +// +// ## VCSes #################### +// + +function getVcses() { + return array('git', 'svn'); +} + +function vcsDetectGit($_fileish) { + // Is there a git executable? + exec('sh -c "git --version" > /dev/null 2>&1', $_out, $exit); + if ($exit !== 0) return array(false, "'git --version' exited with non-zero code '$exit'"); + + // Does this look like a git repo? + $path = findUp('.', '.git'); + return array(!! $path, ($path) ? false : "Could not find .git in current directory or parents"); +} + +function vcsLoadGit($fileish, $base_path, $_default_fileish) { + // We don't care about $default_fileish here - we are expected to load from + // git and we must make a filename to do that. + if (empty($fileish)) { + $fileish = 'HEAD'; + } + + if (strpos($fileish, ':') === false) { + $fileish .= ':' . $base_path . 'composer.lock'; + } + + $lines = array(); + exec('git show ' . escapeshellarg($fileish), $lines, $exit); + + if ($exit !== 0) { + return array('', "'git show $fileish' exited with non-zero code '$exit'"); + } + + return array(mustDecodeJson(implode("\n", $lines), $fileish), false); +} + +function vcsDetectSvn($fileish, $base_path, $default_fileish) { + // Is there a git executable? + exec('sh -c "svn --version" > /dev/null 2>&1', $_out, $exit); + if ($exit !== 0) return array(false, "'svn --version' exited with non-zero code '$exit'"); + + if (strpos('svn://', $fileish) === 0) { + return array(true, false); + } + + // Does this look like a svn repo? + $path = findUp('.', '.svn'); + return array(!! $path, ($path) ? false : "Could not find .svn in current directory or parents"); +} + +function vcsLoadSvn($fileish, $base_path, $_default_fileish) { + // We don't care about $default_fileish here - we are expected to load from + // svn and we must make a filename to do that. + if (empty($fileish)) { + $fileish = 'BASE'; + } + + // If $fileish starts with a url scheme that 'svn cat' can handle or ^, or + // if it contains a @, assume it is already a proper svn identifier. + // - file:// http:// https:// svn:// svn+ssh:// => absolute url of + // repository (file/http/https may have been handled with stream wrappers + // if '--vcs svn' wasn't specified) + // - ^ => relative url from current workspace repository + // - @ => repository url with revision + if (preg_match('#^\^|^(file|http|https|svn|svn\+ssh)://|@#i', $fileish) === 0) { + $fileish = $base_path . 'composer.lock@'.$fileish; + } + + exec('svn cat ' . escapeshellarg($fileish), $lines, $exit); + + if ($exit !== 0) { + return array('', "'svn cat $fileish' exited with non-zero code '$exit'"); + } + + return array(mustDecodeJson(implode("\n", $lines), $fileish), false); +} + +function findUp($path, $filename, $tries = 10) { + if (empty($path)) { + $path = '.'; + } + + // > Trailing delimiters, such as \ and /, are also removed + // > returns false on failure, e.g. if the file does not exist. + $path = realpath($path); + if ($path === false) return false; + + do { + $candidate = joinPath($path, $filename); + + if (file_exists($candidate)) { + return $candidate; + } + + $path = dirnameSafe($path); + } while ($path !== false && --$tries > 0); + + return false; +} + +function dirnameSafe($path) { + $parent = dirname($path); + return ($parent != $path && !empty($parent)) ? $parent : false; +} + +function joinPath(/* path parts */) { + return implode(DIRECTORY_SEPARATOR, array_map(function($part) { + return trim($part, DIRECTORY_SEPARATOR); + }, func_get_args())); +} + +function ensureTrailingPathSep($path) { + return trim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; +} + function parseOpts() { - $given = getopt('hp:', array('path:', 'from:', 'to:', 'md', 'json', 'pretty', 'no-links', 'only-prod', 'only-dev', 'help')); + $given = getopt('hp:', array('path:', 'from:', 'to:', 'md', 'json', 'pretty', 'no-links', 'only-prod', 'only-dev', 'help', 'vcs:')); foreach(array('help' => 'h', 'path' => 'p') as $long => $short) { if (array_key_exists($short, $given)) { @@ -292,9 +434,15 @@ function parseOpts() { usage(); } + $vcs = array_key_exists('vcs', $given) ? $given['vcs'] : ''; + if ($vcs && !function_exists('vcsLoad' . ucfirst($vcs))) { + error_log("Unsupported vcs '$vcs'\n"); + usage(); + } + return array( - 'path' => array_key_exists('path', $given) ? $given['path'] : '', - 'from' => array_key_exists('from', $given) ? $given['from'] : 'HEAD', + 'path' => array_key_exists('path', $given) ? ensureTrailingPathSep($given['path']) : '', + 'from' => array_key_exists('from', $given) ? $given['from'] : '', 'to' => array_key_exists('to', $given) ? $given['to'] : '', 'md' => array_key_exists('md', $given), 'json' => array_key_exists('json', $given), @@ -302,10 +450,12 @@ function parseOpts() { 'no-links' => array_key_exists('no-links', $given), 'only-prod' => array_key_exists('only-prod', $given), 'only-dev' => array_key_exists('only-dev', $given), + 'vcs' => $vcs, ); } function usage() { + $vcses = implode(', ', getVcses()); print <<= 5.4.0) @@ -321,9 +472,11 @@ Options: --no-links Don't include Compare links in plain text or any links in markdown --only-prod Only include changes from `packages` --only-dev Only include changes from `packages-dev` + --vcs Force vcs ($vcses). Default: attempt to auto-detect EOF; exit(0); } +# vim: ff=unix ts=4 ss=4 sr et diff --git a/test-svn.sh b/test-svn.sh new file mode 100755 index 0000000..63694fa --- /dev/null +++ b/test-svn.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" + +svn --help > /dev/null || { echo "Fail: could not find 'svn' executable"; exit 1; } +svnadmin --help > /dev/null || { echo "Fail: could not find 'svnadmin' executable"; exit 1; } + +trap cleanup INT ERR + +function cleanup() { + cd "$DIR/test-data" + rm -rf proj proj-working svnrepo +} + +set -eEx + +cd test-data + +mkdir -p proj/trunk + +cp composer.from.json proj/trunk/composer.json +cp composer.from.lock proj/trunk/composer.lock + +svnadmin create svnrepo +svn import ./proj file://$PWD/svnrepo -m "Initial commit" + +svn checkout file://$PWD/svnrepo proj-working +cp composer.to.json proj-working/trunk/composer.json +cp composer.to.lock proj-working/trunk/composer.lock + +cd proj-working/trunk +../../../composer-lock-diff + +cd .. +../../composer-lock-diff -p trunk + +cd .. +rm -rf proj proj-working svnrepo +