Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F2894460
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Advanced/Developer...
View Handle
View Hovercard
Size
70 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/scripts/arcanist.php b/scripts/arcanist.php
index dfd7309e..c3ec4308 100755
--- a/scripts/arcanist.php
+++ b/scripts/arcanist.php
@@ -1,238 +1,241 @@
#!/usr/bin/env php
<?php
/*
* Copyright 2011 Facebook, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
require_once dirname(__FILE__).'/__init_script__.php';
phutil_require_module('phutil', 'conduit/client');
phutil_require_module('phutil', 'console');
phutil_require_module('phutil', 'future/exec');
phutil_require_module('phutil', 'filesystem');
phutil_require_module('phutil', 'symbols');
phutil_require_module('arcanist', 'exception/usage');
phutil_require_module('arcanist', 'configuration');
phutil_require_module('arcanist', 'workingcopyidentity');
phutil_require_module('arcanist', 'repository/api/base');
ini_set('memory_limit', -1);
$config_trace_mode = false;
$force_conduit = null;
$args = array_slice($argv, 1);
$load = array();
$matches = null;
foreach ($args as $key => $arg) {
if ($arg == '--') {
break;
} else if ($arg == '--trace') {
unset($args[$key]);
$config_trace_mode = true;
} else if ($arg == '--no-ansi') {
unset($args[$key]);
PhutilConsoleFormatter::disableANSI(true);
} else if (preg_match('/^--load-phutil-library=(.*)$/', $arg, $matches)) {
unset($args[$key]);
$load['?'] = $matches[1];
} else if (preg_match('/^--conduit-uri=(.*)$/', $arg, $matches)) {
unset($args[$key]);
$force_conduit = $matches[1];
}
}
if (!posix_isatty(STDOUT)) {
PhutilConsoleFormatter::disableANSI(true);
}
$args = array_values($args);
try {
if ($config_trace_mode) {
ExecFuture::pushEchoMode(true);
}
if (!$args) {
throw new ArcanistUsageException("No command provided. Try 'arc help'.");
}
$working_copy = ArcanistWorkingCopyIdentity::newFromPath($_SERVER['PWD']);
if ($load) {
$libs = $load;
} else {
$libs = $working_copy->getConfig('phutil_libraries');
}
if ($libs) {
foreach ($libs as $name => $location) {
if ($config_trace_mode) {
echo "Loading phutil library '{$name}' from '{$location}'...\n";
}
- $library_root = Filesystem::resolvePath(
+ $resolved_location = Filesystem::resolvePath(
$location,
$working_copy->getProjectRoot());
- phutil_load_library($library_root);
+ if (Filesystem::pathExists($resolved_location)) {
+ $location = $resolved_location;
+ }
+ phutil_load_library($location);
}
}
$user_config = array();
$user_config_path = getenv('HOME').'/.arcrc';
if (Filesystem::pathExists($user_config_path)) {
$user_config_data = Filesystem::readFile($user_config_path);
$user_config = json_decode($user_config_data, true);
if (!is_array($user_config)) {
throw new ArcanistUsageException(
"Your '~/.arcrc' file is not a valid JSON file.");
}
}
$config = $working_copy->getConfig('arcanist_configuration');
if ($config) {
PhutilSymbolLoader::loadClass($config);
$config = new $config();
} else {
$config = new ArcanistConfiguration();
}
$command = strtolower($args[0]);
$workflow = $config->buildWorkflow($command);
if (!$workflow) {
throw new ArcanistUsageException(
"Unknown command '{$command}'. Try 'arc help'.");
}
$workflow->setArcanistConfiguration($config);
$workflow->setCommand($command);
$workflow->parseArguments(array_slice($args, 1));
$need_working_copy = $workflow->requiresWorkingCopy();
$need_conduit = $workflow->requiresConduit();
$need_auth = $workflow->requiresAuthentication();
$need_repository_api = $workflow->requiresRepositoryAPI();
$need_conduit = $need_conduit ||
$need_auth;
$need_working_copy = $need_working_copy ||
$need_conduit ||
$need_repository_api;
if ($need_working_copy) {
if (!$working_copy->getProjectRoot()) {
throw new ArcanistUsageException(
"There is no '.arcconfig' file in this directory or any parent ".
"directory. Create a '.arcconfig' file to configure this project ".
"for use with Arcanist.");
}
$workflow->setWorkingCopy($working_copy);
}
$set_guid = false;
if ($need_conduit) {
if ($force_conduit) {
$conduit_uri = $force_conduit;
} else {
$conduit_uri = $working_copy->getConduitURI();
}
if (!$conduit_uri) {
throw new ArcanistUsageException(
"No Conduit URI is specified in the .arcconfig file for this project. ".
"Specify the Conduit URI for the host Differential is running on.");
}
$conduit = new ConduitClient($conduit_uri);
$conduit->setTraceMode($config_trace_mode);
$workflow->setConduit($conduit);
$hosts_config = idx($user_config, 'hosts', array());
$host_config = idx($hosts_config, $conduit_uri, array());
$user_name = idx($host_config, 'user', getenv('USER'));
$certificate = idx($host_config, 'cert');
$description = implode(' ', $argv);
$connection = $conduit->callMethodSynchronous(
'conduit.connect',
array(
'client' => 'arc',
'clientVersion' => 2,
'clientDescription' => php_uname('n').':'.$description,
'user' => $user_name,
'certificate' => $certificate,
));
$workflow->setUserName($user_name);
$user_phid = idx($connection, 'userPHID');
if ($user_phid) {
$set_guid = true;
$workflow->setUserGUID($user_phid);
}
}
if ($need_repository_api) {
$repository_api = ArcanistRepositoryAPI::newAPIFromWorkingCopyIdentity(
$working_copy);
$workflow->setRepositoryAPI($repository_api);
}
if ($need_auth && !$set_guid) {
$user_name = getenv('USER');
$user_find_future = $conduit->callMethod(
'user.find',
array(
'aliases' => array(
$user_name,
),
));
$user_guids = $user_find_future->resolve();
if (empty($user_guids[$user_name])) {
throw new ArcanistUsageException(
"Username '{$user_name}' is not recognized.");
}
$user_guid = $user_guids[$user_name];
$workflow->setUserGUID($user_guid);
$workflow->setUserName($user_name);
}
$config->willRunWorkflow($command, $workflow);
$workflow->willRunWorkflow();
$err = $workflow->run();
if ($err == 0) {
$config->didRunWorkflow($command, $workflow);
}
exit($err);
} catch (ArcanistUsageException $ex) {
echo phutil_console_format(
"**Usage Exception:** %s\n",
$ex->getMessage());
if ($config_trace_mode) {
echo "\n";
throw $ex;
}
exit(1);
} catch (Exception $ex) {
if ($config_trace_mode) {
throw $ex;
}
echo phutil_console_format(
"\n**Exception:**\n%s\n%s\n",
$ex->getMessage(),
"(Run with --trace for a full exception trace.)");
exit(1);
}
diff --git a/src/lint/patcher/ArcanistLintPatcher.php b/src/lint/patcher/ArcanistLintPatcher.php
index eefb8ad6..de687147 100644
--- a/src/lint/patcher/ArcanistLintPatcher.php
+++ b/src/lint/patcher/ArcanistLintPatcher.php
@@ -1,156 +1,161 @@
<?php
/*
* Copyright 2011 Facebook, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Applies lint patches to the working copy.
*
* @group lint
*/
final class ArcanistLintPatcher {
private $dirtyUntil = 0;
private $characterDelta = 0;
private $modifiedData = null;
private $lineOffsets = null;
private $lintResult = null;
private $applyMessages = array();
public static function newFromArcanistLintResult(ArcanistLintResult $result) {
$obj = new ArcanistLintPatcher();
$obj->lintResult = $result;
return $obj;
}
public function getUnmodifiedFileContent() {
return $this->lintResult->getData();
}
public function getModifiedFileContent() {
if ($this->modifiedData === null) {
$this->buildModifiedFile();
}
return $this->modifiedData;
}
public function writePatchToDisk() {
$path = $this->lintResult->getFilePathOnDisk();
$data = $this->getModifiedFileContent();
$ii = null;
do {
$lint = $path.'.linted'.($ii++);
} while (file_exists($lint));
// Copy existing file to preserve permissions. 'chmod --reference' is not
// supported under OSX.
if (Filesystem::pathExists($path)) {
// This path may not exist if we're generating a new file.
execx('cp -p %s %s', $path, $lint);
}
Filesystem::writeFile($lint, $data);
list($err) = exec_manual("mv -f %s %s", $lint, $path);
if ($err) {
throw new Exception(
"Unable to overwrite path `{$path}', patched version was left ".
"at `{$lint}'.");
}
foreach ($this->applyMessages as $message) {
$message->didApplyPatch();
}
}
private function __construct() {
}
private function buildModifiedFile() {
$data = $this->getUnmodifiedFileContent();
foreach ($this->lintResult->getMessages() as $lint) {
if (!$lint->isPatchable()) {
continue;
}
$orig_offset = $this->getCharacterOffset($lint->getLine() - 1);
$orig_offset += $lint->getChar() - 1;
$dirty = $this->getDirtyCharacterOffset();
if ($dirty > $orig_offset) {
continue;
}
// Adjust the character offset by the delta *after* checking for
// dirtiness. The dirty character cursor is a cursor on the original file,
// and should be compared with the patch position in the original file.
$working_offset = $orig_offset + $this->getCharacterDelta();
$old_str = $lint->getOriginalText();
$old_len = strlen($old_str);
$new_str = $lint->getReplacementText();
$new_len = strlen($new_str);
- $data = substr_replace($data, $new_str, $working_offset, $old_len);
+ if ($working_offset == strlen($data)) {
+ // Temporary hack to work around a destructive hphpi issue, see #451031.
+ $data .= $new_str;
+ } else {
+ $data = substr_replace($data, $new_str, $working_offset, $old_len);
+ }
$this->changeCharacterDelta($new_len - $old_len);
$this->setDirtyCharacterOffset($orig_offset + $old_len);
$this->applyMessages[] = $lint;
}
$this->modifiedData = $data;
}
private function getCharacterOffset($line_num) {
if ($this->lineOffsets === null) {
$lines = explode("\n", $this->getUnmodifiedFileContent());
$this->lineOffsets = array(0);
$last = 0;
foreach ($lines as $line) {
$this->lineOffsets[] = $last + strlen($line) + 1;
$last += strlen($line) + 1;
}
}
if ($line_num >= count($this->lineOffsets)) {
throw new Exception("Data has fewer than `{$line}' lines.");
}
return idx($this->lineOffsets, $line_num);
}
private function setDirtyCharacterOffset($offset) {
$this->dirtyUntil = $offset;
return $this;
}
private function getDirtyCharacterOffset() {
return $this->dirtyUntil;
}
private function changeCharacterDelta($change) {
$this->characterDelta += $change;
return $this;
}
private function getCharacterDelta() {
return $this->characterDelta;
}
}
diff --git a/src/repository/api/subversion/ArcanistSubversionAPI.php b/src/repository/api/subversion/ArcanistSubversionAPI.php
index 7d4cef26..4dd7fa7e 100644
--- a/src/repository/api/subversion/ArcanistSubversionAPI.php
+++ b/src/repository/api/subversion/ArcanistSubversionAPI.php
@@ -1,460 +1,460 @@
<?php
/*
* Copyright 2011 Facebook, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Interfaces with Subversion working copies.
*
* @group workingcopy
*/
class ArcanistSubversionAPI extends ArcanistRepositoryAPI {
protected $svnStatus;
protected $svnBaseRevisions;
protected $svnInfo = array();
protected $svnInfoRaw = array();
protected $svnDiffRaw = array();
-
+
private $svnBaseRevisionNumber;
public function getSourceControlSystemName() {
return 'svn';
}
public function hasMergeConflicts() {
foreach ($this->getSVNStatus() as $path => $mask) {
if ($mask & self::FLAG_CONFLICT) {
return true;
}
}
return false;
}
public function getWorkingCopyStatus() {
return $this->getSVNStatus();
}
public function getSVNBaseRevisions() {
if ($this->svnBaseRevisions === null) {
$this->getSVNStatus();
}
return $this->svnBaseRevisions;
}
public function getSVNStatus($with_externals = false) {
if ($this->svnStatus === null) {
list($status) = execx('(cd %s && svn --xml status)', $this->getPath());
$xml = new SimpleXMLElement($status);
if (count($xml->target) != 1) {
throw new Exception("Expected exactly one XML status target.");
}
$externals = array();
$files = array();
$target = $xml->target[0];
$this->svnBaseRevisions = array();
foreach ($target->entry as $entry) {
$path = (string)$entry['path'];
$mask = 0;
$props = (string)($entry->{'wc-status'}[0]['props']);
$item = (string)($entry->{'wc-status'}[0]['item']);
$base = (string)($entry->{'wc-status'}[0]['revision']);
$this->svnBaseRevisions[$path] = $base;
switch ($props) {
case 'none':
case 'normal':
break;
case 'modified':
$mask |= self::FLAG_MODIFIED;
break;
default:
throw new Exception("Unrecognized property status '{$props}'.");
}
switch ($item) {
case 'normal':
break;
case 'external':
$mask |= self::FLAG_EXTERNALS;
$externals[] = $path;
break;
case 'unversioned':
$mask |= self::FLAG_UNTRACKED;
break;
case 'obstructed':
$mask |= self::FLAG_OBSTRUCTED;
break;
case 'missing':
$mask |= self::FLAG_MISSING;
break;
case 'added':
$mask |= self::FLAG_ADDED;
break;
case 'modified':
$mask |= self::FLAG_MODIFIED;
break;
case 'deleted':
$mask |= self::FLAG_DELETED;
break;
case 'conflicted':
$mask |= self::FLAG_CONFLICT;
break;
default:
throw new Exception("Unrecognized item status '{$item}'.");
}
$files[$path] = $mask;
}
foreach ($files as $path => $mask) {
foreach ($externals as $external) {
if (!strncmp($path, $external, strlen($external))) {
$files[$path] |= self::FLAG_EXTERNALS;
}
}
}
$this->svnStatus = $files;
}
$status = $this->svnStatus;
if (!$with_externals) {
foreach ($status as $path => $mask) {
if ($mask & ArcanistRepositoryAPI::FLAG_EXTERNALS) {
unset($status[$path]);
}
}
}
return $status;
}
public function getSVNProperty($path, $property) {
list($stdout) = execx(
'svn propget %s %s@',
$property,
$this->getPath($path));
return trim($stdout);
}
public function getSourceControlPath() {
return idx($this->getSVNInfo('/'), 'URL');
}
public function getSourceControlBaseRevision() {
$info = $this->getSVNInfo('/');
return $info['URL'].'@'.$this->getSVNBaseRevisionNumber();
}
-
+
public function getSVNBaseRevisionNumber() {
if ($this->svnBaseRevisionNumber) {
return $this->svnBaseRevisionNumber;
}
$info = $this->getSVNInfo('/');
return $info['Revision'];
}
-
+
public function overrideSVNBaseRevisionNumber($effective_base_revision) {
$this->svnBaseRevisionNumber = $effective_base_revision;
return $this;
}
public function getBranchName() {
return 'svn';
}
public function buildInfoFuture($path) {
if ($path == '/') {
// When the root of a working copy is referenced by a symlink and you
// execute 'svn info' on that symlink, svn fails. This is a longstanding
// bug in svn:
//
// See http://subversion.tigris.org/issues/show_bug.cgi?id=2305
//
// To reproduce, do:
//
// $ ln -s working_copy working_link
// $ svn info working_copy # ok
// $ svn info working_link # fails
//
// Work around this by cd-ing into the directory before executing
// 'svn info'.
return new ExecFuture(
'(cd %s && svn info .)',
$this->getPath());
} else {
// Note: here and elsewhere we need to append "@" to the path because if
// a file has a literal "@" in it, everything after that will be
// interpreted as a revision. By appending "@" with no argument, SVN
// parses it properly.
return new ExecFuture(
'svn info %s@',
$this->getPath($path));
}
}
public function buildDiffFuture($path) {
// The "--depth empty" flag prevents us from picking up changes in
// children when we run 'diff' against a directory.
return new ExecFuture(
'(cd %s; svn diff --depth empty --diff-cmd diff -x -U%d %s)',
$this->getPath(),
$this->getDiffLinesOfContext(),
$path);
}
public function primeSVNInfoResult($path, $result) {
$this->svnInfoRaw[$path] = $result;
return $this;
}
public function primeSVNDiffResult($path, $result) {
$this->svnDiffRaw[$path] = $result;
return $this;
}
public function getSVNInfo($path) {
if (empty($this->svnInfo[$path])) {
if (empty($this->svnInfoRaw[$path])) {
$this->svnInfoRaw[$path] = $this->buildInfoFuture($path)->resolve();
}
list($err, $stdout) = $this->svnInfoRaw[$path];
if ($err) {
throw new Exception(
"Error #{$err} executing svn info against '{$path}'.");
}
$patterns = array(
'/^(URL): (\S+)$/m',
'/^(Revision): (\d+)$/m',
'/^(Last Changed Author): (\S+)$/m',
'/^(Last Changed Rev): (\d+)$/m',
'/^(Last Changed Date): (.+) \(.+\)$/m',
'/^(Copied From URL): (\S+)$/m',
'/^(Copied From Rev): (\d+)$/m',
);
$result = array();
foreach ($patterns as $pattern) {
$matches = null;
if (preg_match($pattern, $stdout, $matches)) {
$result[$matches[1]] = $matches[2];
}
}
if (isset($result['Last Changed Date'])) {
$result['Last Changed Date'] = strtotime($result['Last Changed Date']);
}
if (empty($result)) {
throw new Exception('Unable to parse SVN info.');
}
$this->svnInfo[$path] = $result;
}
return $this->svnInfo[$path];
}
public function getRawDiffText($path) {
$status = $this->getSVNStatus();
if (!isset($status[$path])) {
return null;
}
$status = $status[$path];
// Build meaningful diff text for "svn copy" operations.
if ($status & ArcanistRepositoryAPI::FLAG_ADDED) {
$info = $this->getSVNInfo($path);
if (!empty($info['Copied From URL'])) {
return $this->buildSyntheticAdditionDiff(
$path,
$info['Copied From URL'],
$info['Copied From Rev']);
}
}
// If we run "diff" on a binary file which doesn't have the "svn:mime-type"
// of "application/octet-stream", `diff' will explode in a rain of
// unhelpful hellfire as it tries to build a textual diff of the two
// files. We just fix this inline since it's pretty unambiguous.
// TODO: Move this to configuration?
$matches = null;
if (preg_match('/\.(gif|png|jpe?g|swf|pdf|ico)$/i', $path, $matches)) {
$mime = $this->getSVNProperty($path, 'svn:mime-type');
if ($mime != 'application/octet-stream') {
execx(
'svn propset svn:mime-type application/octet-stream %s',
$this->getPath($path));
}
}
if (empty($this->svnDiffRaw[$path])) {
$this->svnDiffRaw[$path] = $this->buildDiffFuture($path)->resolve();
}
list($err, $stdout, $stderr) = $this->svnDiffRaw[$path];
// Note: GNU Diff returns 2 when SVN hands it binary files to diff and they
// differ. This is not an error; it is documented behavior. But SVN isn't
// happy about it. SVN will exit with code 1 and return the string below.
if ($err != 0 && $stderr !== "svn: 'diff' returned 2\n") {
throw new Exception(
"svn diff returned unexpected error code: $err\n".
"stdout: $stdout\n".
"stderr: $stderr");
}
if ($err == 0 && empty($stdout)) {
// If there are no changes, 'diff' exits with no output, but that means
// we can not distinguish between empty and unmodified files. Build a
// synthetic "diff" without any changes in it.
return $this->buildSyntheticUnchangedDiff($path);
}
return $stdout;
}
protected function buildSyntheticAdditionDiff($path, $source, $rev) {
$type = $this->getSVNProperty($path, 'svn:mime-type');
if ($type == 'application/octet-stream') {
return <<<EODIFF
Index: {$path}
===================================================================
Cannot display: file marked as a binary type.
svn:mime-type = application/octet-stream
EODIFF;
}
if (is_dir($this->getPath($path))) {
return null;
}
$data = Filesystem::readFile($this->getPath($path));
list($orig) = execx('svn cat %s@%s', $source, $rev);
$src = new TempFile();
$dst = new TempFile();
Filesystem::writeFile($src, $orig);
Filesystem::writeFile($dst, $data);
list($err, $diff) = exec_manual(
'diff -L a/%s -L b/%s -U%d %s %s',
str_replace($this->getSourceControlPath().'/', '', $source),
$path,
$this->getDiffLinesOfContext(),
$src,
$dst);
if ($err == 1) { // 1 means there are differences.
return <<<EODIFF
Index: {$path}
===================================================================
{$diff}
EODIFF;
} else {
return $this->buildSyntheticUnchangedDiff($path);
}
}
protected function buildSyntheticUnchangedDiff($path) {
$full_path = $this->getPath($path);
if (is_dir($full_path)) {
return null;
}
$data = Filesystem::readFile($full_path);
$lines = explode("\n", $data);
$len = count($lines);
foreach ($lines as $key => $line) {
$lines[$key] = ' '.$line;
}
$lines = implode("\n", $lines);
return <<<EODIFF
Index: {$path}
===================================================================
--- {$path} (synthetic)
+++ {$path} (synthetic)
@@ -1,{$len} +1,{$len} @@
{$lines}
EODIFF;
}
public function getBlame($path) {
$blame = array();
list($stdout) = execx(
'(cd %s && svn blame %s)',
$this->getPath(),
$path);
$stdout = trim($stdout);
if (!strlen($stdout)) {
// Empty file.
return $blame;
}
foreach (explode("\n", $stdout) as $line) {
$m = array();
if (!preg_match('/^\s*(\d+)\s+(\S+)/', $line, $m)) {
throw new Exception("Bad blame? `{$line}'");
}
$revision = $m[1];
$author = $m[2];
$blame[] = array($author, $revision);
}
return $blame;
}
public function getOriginalFileData($path) {
// SVN issues warnings for nonexistent paths, directories, etc., but still
// returns no error code. However, for new paths in the working copy it
// fails. Assume that failure means the original file does not exist.
list($err, $stdout) = exec_manual(
'(cd %s && svn cat %s@)',
$this->getPath(),
$path);
if ($err) {
return null;
}
return $stdout;
}
public function getCurrentFileData($path) {
$full_path = $this->getPath($path);
if (Filesystem::pathExists($full_path)) {
return Filesystem::readFile($full_path);
}
return null;
}
}
diff --git a/src/workflow/diff/ArcanistDiffWorkflow.php b/src/workflow/diff/ArcanistDiffWorkflow.php
index a1d75ef5..3eb23bb1 100644
--- a/src/workflow/diff/ArcanistDiffWorkflow.php
+++ b/src/workflow/diff/ArcanistDiffWorkflow.php
@@ -1,1019 +1,1019 @@
<?php
/*
* Copyright 2011 Facebook, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Sends changes from your working copy to Differential for code review.
*
* @group workflow
*/
class ArcanistDiffWorkflow extends ArcanistBaseWorkflow {
private $hasWarnedExternals = false;
private $unresolvedLint;
private $unresolvedTests;
private $diffID;
public function getCommandHelp() {
return phutil_console_format(<<<EOTEXT
**diff** [__paths__] (svn)
**diff** [__commit__] (git)
Supports: git, svn
Generate a Differential diff or revision from local changes.
Under git, you can specify a commit (like __HEAD^^^__ or __master__)
and Differential will generate a diff against the merge base of that
commit and HEAD. If you omit the commit, the default is __HEAD^__.
Under svn, you can choose to include only some of the modified files
in the working copy in the diff by specifying their paths. If you
omit paths, all changes are included in the diff.
EOTEXT
);
}
public function requiresWorkingCopy() {
return true;
}
public function requiresConduit() {
return true;
}
public function requiresAuthentication() {
return true;
}
public function requiresRepositoryAPI() {
return true;
}
public function getDiffID() {
return $this->diffID;
}
public function getArguments() {
return array(
'message' => array(
'short' => 'm',
'supports' => array(
'git',
),
'nosupport' => array(
'svn' => 'Edit revisions via the web interface when using SVN.',
),
'param' => 'message',
'help' =>
"When updating a revision under git, use the specified message ".
"instead of prompting.",
),
'edit' => array(
'supports' => array(
'git',
),
'nosupport' => array(
'svn' => 'Edit revisions via the web interface when using SVN.',
),
'help' =>
"When updating a revision under git, edit revision information ".
"before updating.",
),
'nounit' => array(
'help' =>
"Do not run unit tests.",
),
'nolint' => array(
'help' =>
"Do not run lint.",
'conflicts' => array(
'lintall' => '--nolint suppresses lint.',
'advice' => '--nolint suppresses lint.',
'apply-patches' => '--nolint suppresses lint.',
'never-apply-patches' => '--nolint suppresses lint.',
),
),
'only' => array(
'help' =>
"Only generate a diff, without running lint, unit tests, or other ".
"auxiliary steps.",
'conflicts' => array(
'preview' => null,
'message' => '--only does not affect revisions.',
'edit' => '--only does not affect revisions.',
'lintall' => '--only suppresses lint.',
'advice' => '--only suppresses lint.',
'apply-patches' => '--only suppresses lint.',
'never-apply-patches' => '--only suppresses lint.',
'nounit' => '--only implies --nounit.',
'nolint' => '--only implies --nolint.',
),
),
'preview' => array(
'supports' => array(
'git',
),
'nosupport' => array(
'svn' => 'Revisions are never created directly when using SVN.',
),
'help' =>
"Instead of creating or updating a revision, only create a diff, ".
"which you may later attach to a revision. This still runs lint ".
"unit tests. See also --only.",
'conflicts' => array(
'only' => null,
'edit' => '--preview does affect revisions.',
'message' => '--preview does not update any revision.',
),
),
'allow-untracked' => array(
'help' =>
"Skip checks for untracked files in the working copy.",
),
'less-context' => array(
'help' =>
"Normally, files are diffed with full context: the entire file is ".
"sent to Differential so reviewers can 'show more' and see it. If ".
"you are making changes to very large files with tens of thousands ".
"of lines, this may not work well. With this flag, a diff will ".
"be created that has only a few lines of context.",
),
'lintall' => array(
'help' =>
"Raise all lint warnings, not just those on lines you changed.",
'passthru' => array(
'lint' => true,
),
),
'advice' => array(
'help' =>
"Raise lint advice in addition to lint warnings and errors.",
'passthru' => array(
'lint' => true,
),
),
'apply-patches' => array(
'help' =>
'Apply patches suggested by lint to the working copy without '.
'prompting.',
'conflicts' => array(
'never-apply-patches' => true,
),
'passthru' => array(
'lint' => true,
),
),
'never-apply-patches' => array(
'help' => 'Never apply patches suggested by lint.',
'conflicts' => array(
'apply-patches' => true,
),
'passthru' => array(
'lint' => true,
),
),
'*' => 'paths',
);
}
public function run() {
$repository_api = $this->getRepositoryAPI();
if ($this->getArgument('less-context')) {
$repository_api->setDiffLinesOfContext(3);
}
$conduit = $this->getConduit();
$this->requireCleanWorkingCopy();
$parent = null;
$paths = $this->generateAffectedPaths();
$lint_result = $this->runLint($paths);
$unit_result = $this->runUnit($paths);
$changes = $this->generateChanges();
if (!$changes) {
throw new ArcanistUsageException(
"There are no changes to generate a diff from!");
}
$change_list = array();
foreach ($changes as $change) {
$change_list[] = $change->toDictionary();
}
if ($lint_result === ArcanistLintWorkflow::RESULT_OKAY) {
$lint = 'okay';
} else if ($lint_result === ArcanistLintWorkflow::RESULT_ERRORS) {
$lint = 'fail';
} else if ($lint_result === ArcanistLintWorkflow::RESULT_WARNINGS) {
$lint = 'warn';
} else if ($lint_result === ArcanistLintWorkflow::RESULT_SKIP) {
$lint = 'skip';
} else {
$lint = 'none';
}
if ($unit_result === ArcanistUnitWorkflow::RESULT_OKAY) {
$unit = 'okay';
} else if ($unit_result === ArcanistUnitWorkflow::RESULT_FAIL) {
$unit = 'fail';
} else if ($unit_result === ArcanistUnitWorkflow::RESULT_UNSOUND) {
$unit = 'warn';
} else if ($unit_result === ArcanistUnitWorkflow::RESULT_SKIP) {
$unit = 'skip';
} else {
$unit = 'none';
}
// NOTE: This has to happen after generateChanges(), since it may overwrite
// the SVN effective base revision.
$base_revision = $repository_api->getSourceControlBaseRevision();
$base_path = $repository_api->getSourceControlPath();
if ($repository_api instanceof ArcanistGitAPI) {
$info = $this->getGitParentLogInfo();
if ($info['parent']) {
$parent = $info['parent'];
}
if ($info['base_revision']) {
$base_revision = $info['base_revision'];
}
if ($info['base_path']) {
$base_path = $info['base_path'];
}
}
$diff = array(
'changes' => $change_list,
'sourceMachine' => php_uname('n'),
'sourcePath' => $repository_api->getPath(),
'branch' => $repository_api->getBranchName(),
'sourceControlSystem' =>
$repository_api->getSourceControlSystemName(),
'sourceControlPath' => $base_path,
'sourceControlBaseRevision' => $base_revision,
'parentRevisionID' => $parent,
'lintStatus' => $lint,
'unitStatus' => $unit,
);
$diff_info = $conduit->callMethodSynchronous(
'differential.creatediff',
$diff);
if ($this->unresolvedLint) {
$data = array();
foreach ($this->unresolvedLint as $message) {
$data[] = array(
'path' => $message->getPath(),
'line' => $message->getLine(),
'char' => $message->getChar(),
'code' => $message->getCode(),
'severity' => $message->getSeverity(),
'name' => $message->getName(),
'description' => $message->getDescription(),
);
}
$conduit->callMethodSynchronous(
'differential.setdiffproperty',
array(
'diff_id' => $diff_info['diffid'],
'name' => 'arc:lint',
'data' => json_encode($data),
));
}
if ($this->unresolvedTests) {
$data = array();
foreach ($this->unresolvedTests as $test) {
$data[] = array(
'name' => $test->getName(),
'result' => $test->getResult(),
'userdata' => $test->getUserData(),
);
}
$conduit->callMethodSynchronous(
'differential.setdiffproperty',
array(
'diff_id' => $diff_info['diffid'],
'name' => 'arc:unit',
'data' => json_encode($data),
));
}
if ($this->shouldOnlyCreateDiff()) {
echo phutil_console_format(
"Created a new Differential diff:\n".
" **Diff URI:** __%s__\n\n",
$diff_info['uri']);
} else {
$message = $this->getGitCommitMessage();
$revision = array(
'diffid' => $diff_info['diffid'],
'fields' => $message->getFields(),
);
if ($message->getRevisionID()) {
// TODO: This is silly -- we're getting a text corpus from the server
// and then sending it right back to be parsed. This should be a
// single call.
$remote_corpus = $conduit->callMethodSynchronous(
'differential.getcommitmessage',
array(
'revision_id' => $message->getRevisionID(),
));
$remote_message = ArcanistDifferentialCommitMessage::newFromRawCorpus(
$remote_corpus);
$remote_message->pullDataFromConduit($conduit);
// TODO: We should throw here if you deleted the 'testPlan'.
$sync = array('title', 'summary', 'testPlan');
foreach ($sync as $field) {
$local = $message->getFieldValue($field);
$remote_message->setFieldValue($field, $local);
}
$should_edit = $this->getArgument('edit');
if (!$should_edit) {
$local_sum = $message->getChecksum();
$remote_sum = $remote_message->getChecksum();
if ($local_sum != $remote_sum) {
$prompt =
"You have made local changes to your commit message. Arcanist ".
"ignores most local changes. Instead, use the '--edit' flag to ".
"edit revision information. Edit revision information now?";
$should_edit = phutil_console_confirm(
$prompt,
$default_no = false);
}
}
if ($should_edit) {
// TODO: lol. But we need differential.unparsecommitmessage or
// something.
throw new ArcanistUsageException(
'--edit is not supported yet. Edit revisions from the web '.
'UI.');
}
$revision['fields'] = $remote_message->getFields();
$update_message = $this->getUpdateMessage();
$revision['id'] = $message->getRevisionID();
$revision['message'] = $update_message;
$future = $conduit->callMethod(
'differential.updaterevision',
$revision);
$result = $future->resolve();
echo "Updated an existing Differential revision:\n";
} else {
$revision['user'] = $this->getUserGUID();
$future = $conduit->callMethod(
'differential.createrevision',
$revision);
$result = $future->resolve();
echo "Updating commit message to include Differential revision ID...\n";
$repository_api->amendGitHeadCommit(
$message->getRawCorpus().
"\n\n".
"Differential Revision: ".$result['revisionid']."\n");
echo "Created a new Differential revision:\n";
}
$uri = $result['uri'];
echo phutil_console_format(
" **Revision URI:** __%s__\n\n",
$uri);
}
echo "Included changes:\n";
foreach ($changes as $change) {
echo ' '.$change->renderTextSummary()."\n";
}
$this->diffID = $diff_info['diffid'];
return 0;
}
protected function shouldOnlyCreateDiff() {
$repository_api = $this->getRepositoryAPI();
if ($repository_api instanceof ArcanistSubversionAPI) {
return true;
}
return $this->getArgument('preview') ||
$this->getArgument('only');
}
protected function findRevisionInformation() {
return array(null, null);
}
private function generateAffectedPaths() {
$repository_api = $this->getRepositoryAPI();
if ($repository_api instanceof ArcanistSubversionAPI) {
$file_list = new FileList($this->getArgument('paths', array()));
$paths = $repository_api->getSVNStatus($externals = true);
foreach ($paths as $path => $mask) {
if (!$file_list->contains($repository_api->getPath($path), true)) {
unset($paths[$path]);
}
}
$warn_externals = array();
foreach ($paths as $path => $mask) {
$any_mod = ($mask & ArcanistRepositoryAPI::FLAG_ADDED) ||
($mask & ArcanistRepositoryAPI::FLAG_MODIFIED) ||
($mask & ArcanistRepositoryAPI::FLAG_DELETED);
if ($mask & ArcanistRepositoryAPI::FLAG_EXTERNALS) {
unset($paths[$path]);
if ($any_mod) {
$warn_externals[] = $path;
}
}
}
if ($warn_externals && !$this->hasWarnedExternals) {
echo phutil_console_format(
"The working copy includes changes to 'svn:externals' paths. These ".
"changes will not be included in the diff because SVN can not ".
"commit 'svn:externals' changes alongside normal changes.".
"\n\n".
"Modified 'svn:externals' files:".
"\n\n".
' '.phutil_console_wrap(implode("\n", $warn_externals), 8));
$prompt = "Generate a diff (with just local changes) anyway?";
if (!phutil_console_confirm($prompt)) {
throw new ArcanistUserAbortException();
} else {
$this->hasWarnedExternals = true;
}
}
} else {
$this->parseGitRelativeCommit(
$repository_api,
$this->getArgument('paths', array()));
$paths = $repository_api->getWorkingCopyStatus();
}
foreach ($paths as $path => $mask) {
if ($mask & ArcanistRepositoryAPI::FLAG_UNTRACKED) {
unset($paths[$path]);
}
}
return $paths;
}
protected function generateChanges() {
$repository_api = $this->getRepositoryAPI();
$parser = new ArcanistDiffParser();
if ($repository_api instanceof ArcanistSubversionAPI) {
$paths = $this->generateAffectedPaths();
$this->primeSubversionWorkingCopyData($paths);
// Check to make sure the user is diffing from a consistent base revision.
// This is mostly just an abuse sanity check because it's silly to do this
// and makes the code more difficult to effectively review, but it also
// affects patches and makes them nonportable.
$bases = $repository_api->getSVNBaseRevisions();
// Remove all files with baserev "0"; these files are new.
foreach ($bases as $path => $baserev) {
if ($bases[$path] == 0) {
unset($bases[$path]);
}
}
if ($bases) {
$rev = reset($bases);
foreach ($bases as $path => $baserev) {
if ($baserev !== $rev) {
$revlist = array();
foreach ($bases as $path => $baserev) {
$revlist[] = " Revision {$baserev}, {$path}";
}
$revlist = implode("\n", $revlist);
throw new ArcanistUsageException(
"Base revisions of changed paths are mismatched. Update all ".
"paths to the same base revision before creating a diff: ".
"\n\n".
$revlist);
}
}
-
+
// If you have a change which affects several files, all of which are
// at a consistent base revision, treat that revision as the effective
// base revision. The use case here is that you made a change to some
// file, which updates it to HEAD, but want to be able to change it
// again without updating the entire working copy. This is a little
// sketchy but it arises in Facebook Ops workflows with config files and
// doesn't have any real material tradeoffs (e.g., these patches are
// perfectly applyable).
$repository_api->overrideSVNBaseRevisionNumber($rev);
}
$changes = $parser->parseSubversionDiff(
$repository_api,
$paths);
} else if ($repository_api instanceof ArcanistGitAPI) {
$diff = $repository_api->getFullGitDiff();
if (!strlen($diff)) {
list($base, $tip) = $repository_api->getCommitRange();
if ($tip == 'HEAD') {
if (preg_match('/\^+HEAD/', $base)) {
$more = 'Did you mean HEAD^ instead of ^HEAD?';
} else {
$more = 'Did you specify the wrong relative commit?';
}
} else {
$more = 'Did you specify the wrong commit range?';
}
throw new ArcanistUsageException("No changes found. ({$more})");
}
$changes = $parser->parseDiff($diff);
} else {
throw new Exception("Repository API is not supported.");
}
if (count($changes) > 250) {
$count = number_format(count($changes));
$message =
"This diff has a very large number of changes ({$count}). ".
"Differential works best for changes which will receive detailed ".
"human review, and not as well for large automated changes or ".
"bulk checkins. Continue anyway?";
if (!phutil_console_confirm($message)) {
throw new ArcanistUsageException(
"Aborted generation of gigantic diff.");
}
}
$limit = 1024 * 1024 * 4;
foreach ($changes as $change) {
$size = 0;
foreach ($change->getHunks() as $hunk) {
$size += strlen($hunk->getCorpus());
}
if ($size > $limit) {
$file_name = $change->getCurrentPath();
$change_size = number_format($size);
$byte_warning =
"Diff for '{$file_name}' with context is {$change_size} bytes in ".
"length. Generally, source changes should not be this large. If ".
"this file is a huge text file, try using the '--less-context' flag.";
if ($repository_api instanceof ArcanistSubversionAPI) {
throw new ArcanistUsageException(
"{$byte_warning} If the file is not a text file, mark it as ".
"binary with:".
"\n\n".
" $ svn propset svn:mime-type application/octet-stream <filename>".
"\n");
} else {
$confirm =
"{$byte_warning} If the file is not a text file, you can ".
"mark it 'binary'. Mark this file as 'binary' and continue?";
if (phutil_console_confirm($confirm)) {
$change->convertToBinaryChange();
} else {
throw new ArcanistUsageException(
"Aborted generation of gigantic diff.");
}
}
}
}
// TODO: Ideally, we should do this later, after validating commit message
// fields (i.e., test plan), in case there are large/slow file upload steps
// involved.
foreach ($changes as $change) {
if ($change->getFileType() != ArcanistDiffChangeType::FILE_BINARY) {
continue;
}
$path = $change->getCurrentPath();
$old_file = $repository_api->getOriginalFileData($path);
$new_file = $repository_api->getCurrentFileData($path);
$old_dict = $this->uploadFile($old_file, basename($path), 'old binary');
$new_dict = $this->uploadFile($new_file, basename($path), 'new binary');
if ($old_dict['guid']) {
$change->setMetadata('old:binary-guid', $old_dict['guid']);
}
if ($new_dict['guid']) {
$change->setMetadata('new:binary-guid', $new_dict['guid']);
}
$change->setMetadata('old:file:size', strlen($old_file));
$change->setMetadata('new:file:size', strlen($new_file));
$change->setMetadata('old:file:mime-type', $old_dict['mime']);
$change->setMetadata('new:file:mime-type', $new_dict['mime']);
if (preg_match('@^image/@', $new_dict['mime'])) {
$change->setFileType(ArcanistDiffChangeType::FILE_IMAGE);
}
}
return $changes;
}
private function uploadFile($data, $name, $desc) {
$result = array(
'guid' => null,
'mime' => null,
);
if (!strlen($data)) {
return $result;
}
$future = new ExecFuture('file -ib -');
$future->write($data);
list($mime_type) = $future->resolvex();
$mime_type = trim($mime_type);
if (strpos($mime_type, ',') !== false) {
// TODO: This is kind of silly, but 'file -ib' goes crazy on executables.
$mime_type = reset(explode(',', $mime_type));
}
$result['mime'] = $mime_type;
// TODO: Make this configurable.
$bin_limit = 1024 * 1024; // 1 MB limit
if (strlen($data) > $bin_limit) {
return $result;
}
$bytes = strlen($data);
echo "Uploading {$desc} '{$name}' ({$mime_type}, {$bytes} bytes)...\n";
$guid = $this->getConduit()->callMethodSynchronous(
'file.upload',
array(
'data_base64' => base64_encode($data),
'name' => $name,
));
$result['guid'] = $guid;
return $result;
}
/**
* Retrieve the git message in HEAD if it isn't a primary template message.
*/
private function getGitUpdateMessage() {
$repository_api = $this->getRepositoryAPI();
$parser = new ArcanistDiffParser($repository_api);
$commit_messages = $repository_api->getGitCommitLog();
$commit_messages = $parser->parseDiff($commit_messages);
$head = reset($commit_messages);
$message = ArcanistDifferentialCommitMessage::newFromRawCorpus(
$head->getMetadata('message'));
if ($message->getRevisionID()) {
return null;
}
return trim($message->getRawCorpus());
}
private function getGitCommitMessage() {
$conduit = $this->getConduit();
$repository_api = $this->getRepositoryAPI();
$parser = new ArcanistDiffParser($repository_api);
$commit_messages = $repository_api->getGitCommitLog();
$commit_messages = $parser->parseDiff($commit_messages);
$problems = array();
$parsed = array();
foreach ($commit_messages as $key => $change) {
$problems[$key] = array();
try {
$message = ArcanistDifferentialCommitMessage::newFromRawCorpus(
$change->getMetadata('message'));
$message->pullDataFromConduit($conduit);
$parsed[$key] = $message;
} catch (ArcanistDifferentialCommitMessageParserException $ex) {
$problems[$key][] = $ex;
continue;
}
// TODO: Move this all behind Conduit.
if (!$message->getRevisionID()) {
if ($message->getFieldValue('reviewedByGUIDs')) {
$problems[$key][] = new ArcanistUsageException(
"When creating or updating a revision, use the 'Reviewers:' ".
"field to specify reviewers, not 'Reviewed By:'. After the ".
"revision is accepted, run 'arc amend' to update the commit ".
"message.");
}
if (!$message->getFieldValue('title')) {
$problems[$key][] = new ArcanistUsageException(
"Commit message has no title. You must provide a title for this ".
"revision.");
}
if (!$message->getFieldValue('testPlan')) {
$problems[$key][] = new ArcanistUsageException(
"Commit message has no 'Test Plan:'. You must provide a test ".
"plan.");
}
}
}
$blessed = null;
$revision_id = -1;
foreach ($problems as $key => $problem_list) {
if ($problem_list) {
continue;
}
if ($revision_id === -1) {
$revision_id = $parsed[$key]->getRevisionID();
$blessed = $parsed[$key];
} else {
throw new ArcanistUsageException(
"Changes in the specified commit range include more than one ".
"commit with a valid template commit message. This is ambiguous, ".
"your commit range should contain only one template commit ".
"message. Alternatively, use --preview to ignore commit ".
"messages.");
}
}
if ($revision_id === -1) {
$all_problems = call_user_func_array('array_merge', $problems);
$desc = implode("\n", mpull($all_problems, 'getMessage'));
if (count($problems) > 1) {
throw new ArcanistUsageException(
"All changes between the specified commits have template parsing ".
"problems:\n\n".$desc."\n\nIf you only want to create a diff ".
"(not a revision), use --preview to ignore commit messages.");
} else if (count($problems) == 1) {
throw new ArcanistUsageException(
"Commit message is not properly formatted:\n\n".$desc."\n\n".
"You should use the standard git commit template to provide a ".
"commit message. If you only want to create a diff (not a ".
"revision), use --preview to ignore commit messages.");
}
}
if ($blessed) {
if (!$blessed->getFieldValue('reviewerGUIDs') &&
!$blessed->getFieldValue('reviewerPHIDs')) {
$message = "You have not specified any reviewers. Continue anyway?";
if (!phutil_console_confirm($message)) {
throw new ArcanistUsageException('Specify reviewers and retry.');
}
}
}
return $blessed;
}
private function getGitParentLogInfo() {
$info = array(
'parent' => null,
'base_revision' => null,
'base_path' => null,
);
$conduit = $this->getConduit();
$repository_api = $this->getRepositoryAPI();
$parser = new ArcanistDiffParser($repository_api);
$history_messages = $repository_api->getGitHistoryLog();
if (!$history_messages) {
// This can occur on the initial commit.
return $info;
}
$history_messages = $parser->parseDiff($history_messages);
foreach ($history_messages as $key => $change) {
try {
$message = ArcanistDifferentialCommitMessage::newFromRawCorpus(
$change->getMetadata('message'));
if ($message->getRevisionID() && $info['parent'] === null) {
$info['parent'] = $message->getRevisionID();
}
if ($message->getGitSVNBaseRevision() &&
$info['base_revision'] === null) {
$info['base_revision'] = $message->getGitSVNBaseRevision();
$info['base_path'] = $message->getGitSVNBasePath();
}
if ($info['parent'] && $info['base_revision']) {
break;
}
} catch (ArcanistDifferentialCommitMessageParserException $ex) {
// Ignore.
}
}
return $info;
}
protected function primeSubversionWorkingCopyData($paths) {
$repository_api = $this->getRepositoryAPI();
$futures = array();
$targets = array();
foreach ($paths as $path => $mask) {
$futures[] = $repository_api->buildDiffFuture($path);
$targets[] = array('command' => 'diff', 'path' => $path);
$futures[] = $repository_api->buildInfoFuture($path);
$targets[] = array('command' => 'info', 'path' => $path);
}
foreach ($futures as $key => $future) {
$target = $targets[$key];
if ($target['command'] == 'diff') {
$repository_api->primeSVNDiffResult(
$target['path'],
$future->resolve());
} else {
$repository_api->primeSVNInfoResult(
$target['path'],
$future->resolve());
}
}
}
private function getUpdateMessage() {
$comments = $this->getArgument('message');
if (!strlen($comments)) {
// When updating a revision using git without specifying '--message', try
// to prefill with the message in HEAD if it isn't a template message. The
// idea is that if you do:
//
// $ git commit -a -m 'fix some junk'
// $ arc diff
//
// ...you shouldn't have to retype the update message.
$repository_api = $this->getRepositoryAPI();
if ($repository_api instanceof ArcanistGitAPI) {
$comments = $this->getGitUpdateMessage();
}
$template =
$comments.
"\n\n".
"# Enter a brief description of the changes included in this update.".
"\n";
$comments = id(new PhutilInteractiveEditor($template))
->setName('differential-update-comments')
->editInteractively();
$comments = preg_replace('/^\s*#.*$/m', '', $comments);
$comments = rtrim($comments);
if (!strlen($comments)) {
throw new ArcanistUserAbortException();
}
}
return $comments;
}
private function runLint($paths) {
if ($this->getArgument('nolint') ||
$this->getArgument('only')) {
return ArcanistLintWorkflow::RESULT_SKIP;
}
$repository_api = $this->getRepositoryAPI();
echo "Linting...\n";
try {
$argv = $this->getPassthruArgumentsAsArgv('lint');
if ($repository_api instanceof ArcanistSubversionAPI) {
$argv = array_merge($argv, array_keys($paths));
} else {
$argv[] = $repository_api->getRelativeCommit();
}
$lint_workflow = $this->buildChildWorkflow('lint', $argv);
$lint_workflow->setShouldAmendChanges(true);
$lint_result = $lint_workflow->run();
switch ($lint_result) {
case ArcanistLintWorkflow::RESULT_OKAY:
echo phutil_console_format(
"<bg:green>** LINT OKAY **</bg> No lint problems.\n");
break;
case ArcanistLintWorkflow::RESULT_WARNINGS:
$continue = phutil_console_confirm(
"Lint issued unresolved warnings. Ignore them?");
if (!$continue) {
throw new ArcanistUserAbortException();
}
break;
case ArcanistLintWorkflow::RESULT_ERRORS:
echo phutil_console_format(
"<bg:red>** LINT ERRORS **</bg> Lint raised errors!\n");
$continue = phutil_console_confirm(
"Lint issued unresolved errors! Ignore lint errors?");
if (!$continue) {
throw new ArcanistUserAbortException();
}
break;
}
$this->unresolvedLint = $lint_workflow->getUnresolvedMessages();
return $lint_result;
} catch (ArcanistNoEngineException $ex) {
echo "No lint engine configured for this project.\n";
} catch (ArcanistNoEffectException $ex) {
echo "No paths to lint.\n";
}
return null;
}
private function runUnit($paths) {
if ($this->getArgument('nounit') ||
$this->getArgument('only')) {
return ArcanistUnitWorkflow::RESULT_SKIP;
}
$repository_api = $this->getRepositoryAPI();
echo "Running unit tests...\n";
try {
$argv = $this->getPassthruArgumentsAsArgv('unit');
if ($repository_api instanceof ArcanistSubversionAPI) {
$argv = array_merge($argv, array_keys($paths));
}
$unit_workflow = $this->buildChildWorkflow('unit', $argv);
$unit_result = $unit_workflow->run();
switch ($unit_result) {
case ArcanistUnitWorkflow::RESULT_OKAY:
echo phutil_console_format(
"<bg:green>** UNIT OKAY **</bg> No unit test failures.\n");
break;
case ArcanistUnitWorkflow::RESULT_UNSOUND:
$continue = phutil_console_confirm(
"Unit test results included failures, but all failing tests ".
"are known to be unsound. Ignore unsound test failures?");
if (!$continue) {
throw new ArcanistUserAbortException();
}
break;
case ArcanistUnitWorkflow::RESULT_FAIL:
echo phutil_console_format(
"<bg:red>** UNIT ERRORS **</bg> Unit testing raised errors!\n");
$continue = phutil_console_confirm(
"Unit test results include failures! Ignore test failures?");
if (!$continue) {
throw new ArcanistUserAbortException();
}
break;
}
$this->unresolvedTests = $unit_workflow->getUnresolvedTests();
return $unit_result;
} catch (ArcanistNoEngineException $ex) {
echo "No unit test engine is configured for this project.\n";
} catch (ArcanistNoEffectException $ex) {
echo "No tests to run.\n";
}
return null;
}
}
diff --git a/src/workflow/svn-hook-pre-commit/ArcanistSvnHookPreCommitWorkflow.php b/src/workflow/svn-hook-pre-commit/ArcanistSvnHookPreCommitWorkflow.php
index 68620928..f0caaefb 100644
--- a/src/workflow/svn-hook-pre-commit/ArcanistSvnHookPreCommitWorkflow.php
+++ b/src/workflow/svn-hook-pre-commit/ArcanistSvnHookPreCommitWorkflow.php
@@ -1,243 +1,244 @@
<?php
/*
* Copyright 2011 Facebook, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Installable as an SVN "pre-commit" hook.
*
* @group workflow
*/
class ArcanistSvnHookPreCommitWorkflow extends ArcanistBaseWorkflow {
public function getCommandHelp() {
return phutil_console_format(<<<EOTEXT
**svn-hook-pre-commit** __repository__ __transaction__
Supports: svn
You can install this as an SVN pre-commit hook. For more information,
see the article "Installing Arcanist SVN Hooks" in the Arcanist
documentation.
EOTEXT
);
}
public function getArguments() {
return array(
'*' => 'svnargs',
);
}
public function shouldShellComplete() {
return false;
}
public function run() {
$svnargs = $this->getArgument('svnargs');
$repository = $svnargs[0];
$transaction = $svnargs[1];
list($commit_message) = execx(
'svnlook log --transaction %s %s',
$transaction,
$repository);
if (strpos($commit_message, '@bypass-lint') !== false) {
return 0;
}
// TODO: Do stuff with commit message.
list($changed) = execx(
'svnlook changed --transaction %s %s',
$transaction,
$repository);
$paths = array();
$changed = explode("\n", trim($changed));
foreach ($changed as $line) {
$matches = null;
preg_match('/^..\s*(.*)$/', $line, $matches);
$paths[$matches[1]] = strlen($matches[1]);
}
$resolved = array();
$failed = array();
$missing = array();
$found = array();
asort($paths);
foreach ($paths as $path => $length) {
foreach ($resolved as $rpath => $root) {
if (!strncmp($path, $rpath, strlen($rpath))) {
$resolved[$path] = $root;
continue 2;
}
}
$config = $path;
if (basename($config) == '.arcconfig') {
$resolved[$config] = $config;
continue;
}
$config = rtrim($config, '/');
$last_config = $config;
do {
if (!empty($missing[$config])) {
break;
} else if (!empty($found[$config])) {
$resolved[$path] = $found[$config];
break;
}
list($err) = exec_manual(
'svnlook cat --transaction %s %s %s',
$transaction,
$repository,
$config ? $config.'/.arcconfig' : '.arcconfig');
if ($err) {
$missing[$path] = true;
} else {
$resolved[$path] = $config ? $config.'/.arcconfig' : '.arcconfig';
$found[$config] = $resolved[$path];
}
$config = dirname($config);
if ($config == '.') {
$config = '';
}
if ($config == $last_config) {
break;
}
$last_config = $config;
} while (true);
if (empty($resolved[$path])) {
$failed[] = $path;
}
}
if ($failed && $resolved) {
$failed_paths = ' '.implode("\n ", $failed);
$resolved_paths = ' '.implode("\n ", array_keys($resolved));
throw new ArcanistUsageException(
"This commit includes a mixture of files in Arcanist projects and ".
"outside of Arcanist projects. A commit which affects an Arcanist ".
"project must affect only that project.\n\n".
"Files in projects:\n\n".
$resolved_paths."\n\n".
"Files not in projects:\n\n".
$failed_paths);
}
if (!$resolved) {
// None of the affected paths are beneath a .arcconfig file.
return 0;
}
$groups = array();
foreach ($resolved as $path => $project) {
$groups[$project][] = $path;
}
if (count($groups) > 1) {
$message = array();
foreach ($groups as $config => $group) {
$message[] = "Files underneath '{$config}':\n\n";
$message[] = " ".implode("\n ", $group)."\n\n";
}
$message = implode('', $message);
throw new ArcanistUsageException(
"This commit includes a mixture of files from different Arcanist ".
"projects. A commit which affects an Arcanist project must affect ".
"only that project.\n\n".
$message);
}
$config_file = key($groups);
$project_root = dirname($config_file);
$paths = reset($groups);
list($config) = execx(
'svnlook cat --transaction %s %s %s',
$transaction,
$repository,
$config_file);
$data = array();
foreach ($paths as $path) {
// TODO: This should be done in parallel.
list($err, $filedata) = exec_manual(
'svnlook cat --transaction %s %s %s',
$transaction,
$repository,
$path);
$data[$path] = $err ? null : $filedata;
}
$working_copy = ArcanistWorkingCopyIdentity::newFromRootAndConfigFile(
$project_root,
- $config);
+ $config,
+ $config_file." (svnlook: {$transaction} {$repository})");
$lint_engine = $working_copy->getConfig('lint_engine');
if (!$lint_engine) {
return 0;
}
PhutilSymbolLoader::loadClass($lint_engine);
$engine = newv($lint_engine, array());
$engine->setWorkingCopy($working_copy);
$engine->setMinimumSeverity(ArcanistLintSeverity::SEVERITY_ERROR);
$engine->setPaths(array_keys($data));
$engine->setFileData($data);
$engine->setCommitHookMode(true);
try {
$results = $engine->run();
} catch (ArcanistNoEffectException $no_effect) {
// Nothing to do, bail out.
return 0;
}
$renderer = new ArcanistLintRenderer();
$failures = array();
foreach ($results as $result) {
if (!$result->getMessages()) {
continue;
}
$failures[] = $result;
}
if ($failures) {
$at = "@";
$msg = phutil_console_format(
"\n**LINT ERRORS**\n\n".
"This changeset has lint errors. You must fix all lint errors before ".
"you can commit.\n\n".
"You can add '{$at}bypass-lint' to your commit message to disable ".
"lint checks for this commit, or '{$at}nolint' to the file with ".
"errors to disable lint for that file.\n\n");
echo phutil_console_wrap($msg);
foreach ($failures as $result) {
echo $renderer->renderLintResult($result);
}
return 1;
}
return 0;
}
}
diff --git a/src/workingcopyidentity/ArcanistWorkingCopyIdentity.php b/src/workingcopyidentity/ArcanistWorkingCopyIdentity.php
index 76992e9c..7763ac9a 100644
--- a/src/workingcopyidentity/ArcanistWorkingCopyIdentity.php
+++ b/src/workingcopyidentity/ArcanistWorkingCopyIdentity.php
@@ -1,95 +1,101 @@
<?php
/*
* Copyright 2011 Facebook, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Interfaces with basic information about the working copy.
*
* @group workingcopy
*/
class ArcanistWorkingCopyIdentity {
protected $projectConfig;
protected $projectRoot;
public static function newFromPath($path) {
$project_id = null;
$project_root = null;
$config = array();
foreach (Filesystem::walkToRoot($path) as $dir) {
$config_file = $dir.'/.arcconfig';
if (!Filesystem::pathExists($config_file)) {
continue;
}
$proj_raw = Filesystem::readFile($config_file);
- $config = self::parseRawConfigFile($proj_raw);
+ $config = self::parseRawConfigFile($proj_raw, $config_file);
$project_root = $dir;
break;
}
return new ArcanistWorkingCopyIdentity($project_root, $config);
}
- public static function newFromRootAndConfigFile($root, $config_raw) {
- $config = self::parseRawConfigFile($config_raw);
+ public static function newFromRootAndConfigFile(
+ $root,
+ $config_raw,
+ $from_where) {
+
+ $config = self::parseRawConfigFile($config_raw, $from_where);
return new ArcanistWorkingCopyIdentity($root, $config);
}
- private static function parseRawConfigFile($raw_config) {
+ private static function parseRawConfigFile($raw_config, $from_where) {
$proj = json_decode($raw_config, true);
if (!is_array($proj)) {
throw new Exception(
- "Unable to parse '.arcconfig' file in '{$dir}'. The file contents ".
- "should be valid JSON.");
+ "Unable to parse '.arcconfig' file '{$from_where}'. The file contents ".
+ "should be valid JSON.\n\n".
+ "FILE CONTENTS\n".
+ substr($raw_config, 0, 2048));
}
$required_keys = array(
'project_id',
);
foreach ($required_keys as $key) {
if (!array_key_exists($key, $proj)) {
throw new Exception(
- "Required key '{$key}' is missing from '.arcconfig' file in ".
- "'{$dir}'.");
+ "Required key '{$key}' is missing from '.arcconfig' file ".
+ "'{$from_where}'.");
}
}
return $proj;
}
protected function __construct($root, array $config) {
$this->projectRoot = $root;
$this->projectConfig = $config;
}
public function getProjectID() {
return $this->getConfig('project_id');
}
public function getProjectRoot() {
return $this->projectRoot;
}
public function getConduitURI() {
return $this->getConfig('conduit_uri');
}
public function getConfig($key) {
if (!empty($this->projectConfig[$key])) {
return $this->projectConfig[$key];
}
return null;
}
}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sun, Jan 19, 19:54 (1 w, 5 d ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1128197
Default Alt Text
(70 KB)
Attached To
Mode
rARC Arcanist
Attached
Detach File
Event Timeline
Log In to Comment