diff --git a/src/parser/ArcanistBundle.php b/src/parser/ArcanistBundle.php index ca89659d..6663c86c 100644 --- a/src/parser/ArcanistBundle.php +++ b/src/parser/ArcanistBundle.php @@ -1,774 +1,798 @@ conduit = $conduit; return $this; } public function setProjectID($project_id) { $this->projectID = $project_id; return $this; } public function getProjectID() { return $this->projectID; } public function setBaseRevision($base_revision) { $this->baseRevision = $base_revision; return $this; } public function setEncoding($encoding) { $this->encoding = $encoding; return $this; } public function getEncoding() { return $this->encoding; } public function getBaseRevision() { return $this->baseRevision; } public function setRevisionID($revision_id) { $this->revisionID = $revision_id; return $this; } public function getRevisionID() { return $this->revisionID; } public static function newFromChanges(array $changes) { $obj = new ArcanistBundle(); $obj->changes = $changes; return $obj; } public static function newFromArcBundle($path) { $path = Filesystem::resolvePath($path); $future = new ExecFuture( csprintf( 'tar tfO %s', $path)); list($stdout, $file_list) = $future->resolvex(); $file_list = explode("\n", trim($file_list)); if (in_array('meta.json', $file_list)) { $future = new ExecFuture( csprintf( 'tar xfO %s meta.json', $path)); $meta_info = $future->resolveJSON(); $version = idx($meta_info, 'version', 0); $project_name = idx($meta_info, 'projectName'); $base_revision = idx($meta_info, 'baseRevision'); $revision_id = idx($meta_info, 'revisionID'); $encoding = idx($meta_info, 'encoding'); // this arc bundle was probably made before we started storing meta info } else { $version = 0; $project_name = null; $base_revision = null; $revision_id = null; $encoding = null; } $future = new ExecFuture( csprintf( 'tar xfO %s changes.json', $path)); $changes = $future->resolveJSON(); foreach ($changes as $change_key => $change) { foreach ($change['hunks'] as $key => $hunk) { list($hunk_data) = execx('tar xfO %s hunks/%s', $path, $hunk['corpus']); $changes[$change_key]['hunks'][$key]['corpus'] = $hunk_data; } } foreach ($changes as $change_key => $change) { $changes[$change_key] = ArcanistDiffChange::newFromDictionary($change); } $obj = new ArcanistBundle(); $obj->changes = $changes; $obj->diskPath = $path; $obj->setProjectID($project_name); $obj->setBaseRevision($base_revision); $obj->setRevisionID($revision_id); $obj->setEncoding($encoding); return $obj; } public static function newFromDiff($data) { $obj = new ArcanistBundle(); $parser = new ArcanistDiffParser(); $obj->changes = $parser->parseDiff($data); return $obj; } private function __construct() { } public function writeToDisk($path) { $changes = $this->getChanges(); $change_list = array(); foreach ($changes as $change) { $change_list[] = $change->toDictionary(); } $hunks = array(); foreach ($change_list as $change_key => $change) { foreach ($change['hunks'] as $key => $hunk) { $hunks[] = $hunk['corpus']; $change_list[$change_key]['hunks'][$key]['corpus'] = count($hunks) - 1; } } $blobs = array(); foreach ($change_list as $change) { if (!empty($change['metadata']['old:binary-phid'])) { $blobs[$change['metadata']['old:binary-phid']] = null; } if (!empty($change['metadata']['new:binary-phid'])) { $blobs[$change['metadata']['new:binary-phid']] = null; } } foreach ($blobs as $phid => $null) { $blobs[$phid] = $this->getBlob($phid); } $meta_info = array( 'version' => 3, 'projectName' => $this->getProjectID(), 'baseRevision' => $this->getBaseRevision(), 'revisionID' => $this->getRevisionID(), 'encoding' => $this->getEncoding(), ); $dir = Filesystem::createTemporaryDirectory(); Filesystem::createDirectory($dir.'/hunks'); Filesystem::createDirectory($dir.'/blobs'); Filesystem::writeFile($dir.'/changes.json', json_encode($change_list)); Filesystem::writeFile($dir.'/meta.json', json_encode($meta_info)); foreach ($hunks as $key => $hunk) { Filesystem::writeFile($dir.'/hunks/'.$key, $hunk); } foreach ($blobs as $key => $blob) { Filesystem::writeFile($dir.'/blobs/'.$key, $blob); } execx( '(cd %s; tar -czf %s *)', $dir, Filesystem::resolvePath($path)); Filesystem::remove($dir); } public function toUnifiedDiff() { $result = array(); $changes = $this->getChanges(); foreach ($changes as $change) { $hunk_changes = $this->buildHunkChanges($change->getHunks()); if (!$hunk_changes) { continue; } $old_path = $this->getOldPath($change); $cur_path = $this->getCurrentPath($change); $index_path = $cur_path; if ($index_path === null) { $index_path = $old_path; } $result[] = 'Index: '.$index_path; $result[] = PHP_EOL; $result[] = str_repeat('=', 67); $result[] = PHP_EOL; if ($old_path === null) { $old_path = '/dev/null'; } if ($cur_path === null) { $cur_path = '/dev/null'; } // When the diff is used by `patch`, `patch` ignores what is listed as the // current path and just makes changes to the file at the old path (unless // the current path is '/dev/null'. // If the old path and the current path aren't the same (and neither is // /dev/null), this indicates the file was moved or copied. By listing // both paths as the new file, `patch` will apply the diff to the new // file. if ($cur_path !== '/dev/null' && $old_path !== '/dev/null') { $old_path = $cur_path; } $result[] = '--- '.$old_path.PHP_EOL; $result[] = '+++ '.$cur_path.PHP_EOL; $result[] = $hunk_changes; } if (!$result) { return ''; } $diff = implode('', $result); return $this->convertNonUTF8Diff($diff); } public function toGitPatch() { $result = array(); $changes = $this->getChanges(); foreach (array_keys($changes) as $multicopy_key) { $multicopy_change = $changes[$multicopy_key]; $type = $multicopy_change->getType(); if ($type != ArcanistDiffChangeType::TYPE_MULTICOPY) { continue; } // Decompose MULTICOPY into one MOVE_HERE and several COPY_HERE because // we need more information than we have in order to build a delete patch // and represent it as a bunch of COPY_HERE plus a delete. For details, // see T419. // Basically, MULTICOPY means there are 2 or more corresponding COPY_HERE // changes, so find one of them arbitrarily and turn it into a MOVE_HERE. // TODO: We might be able to do this more cleanly after T230 is resolved. $decompose_okay = false; foreach ($changes as $change_key => $change) { if ($change->getType() != ArcanistDiffChangeType::TYPE_COPY_HERE) { continue; } if ($change->getOldPath() != $multicopy_change->getCurrentPath()) { continue; } $decompose_okay = true; $change = clone $change; $change->setType(ArcanistDiffChangeType::TYPE_MOVE_HERE); $changes[$change_key] = $change; // The multicopy is now fully represented by MOVE_HERE plus one or more // COPY_HERE, so throw it away. unset($changes[$multicopy_key]); break; } if (!$decompose_okay) { throw new Exception( "Failed to decompose multicopy changeset in order to generate diff."); } } $old_file_phids = array(); foreach ($changes as $change) { + if (!$this->isGitBinaryChange($change)) { + continue; + } + $type = $change->getType(); if ($type == ArcanistDiffChangeType::TYPE_MOVE_AWAY) { - $file_type = $change->getFileType(); - $is_binary = ($file_type == ArcanistDiffChangeType::FILE_BINARY || - $file_type == ArcanistDiffChangeType::FILE_IMAGE); - if ($is_binary) { - foreach ($change->getAwayPaths() as $path) { - $old_file_phids[$path] = $change->getMetadata('old:binary-phid'); - } + foreach ($change->getAwayPaths() as $path) { + $old_file_phids[$path] = $change->getMetadata('old:binary-phid'); } } } foreach ($changes as $change) { $type = $change->getType(); $file_type = $change->getFileType(); if ($file_type == ArcanistDiffChangeType::FILE_DIRECTORY) { // TODO: We should raise a FYI about this, so the user is aware // that we omitted it, if the directory is empty or has permissions // which git can't represent. // Git doesn't support empty directories, so we simply ignore them. If // the directory is nonempty, 'git apply' will create it when processing // the changesets for files inside it. continue; } if ($type == ArcanistDiffChangeType::TYPE_MOVE_AWAY) { // Git will apply this in the corresponding MOVE_HERE. continue; } $old_mode = idx($change->getOldProperties(), 'unix:filemode', '100644'); $new_mode = idx($change->getNewProperties(), 'unix:filemode', '100644'); - $is_binary = ($file_type == ArcanistDiffChangeType::FILE_BINARY || - $file_type == ArcanistDiffChangeType::FILE_IMAGE); + $is_binary = $this->isGitBinaryChange($change); if ($is_binary) { $old_phid = idx($old_file_phids, $this->getCurrentPath($change)); $change_body = $this->buildBinaryChange($change, $old_phid); } else { $change_body = $this->buildHunkChanges($change->getHunks()); } if ($type == ArcanistDiffChangeType::TYPE_COPY_AWAY) { // TODO: This is only relevant when patching old Differential diffs // which were created prior to arc pruning TYPE_COPY_AWAY for files // with no modifications. if (!strlen($change_body) && ($old_mode == $new_mode)) { continue; } } $old_path = $this->getOldPath($change); $cur_path = $this->getCurrentPath($change); if ($old_path === null) { $old_index = 'a/'.$cur_path; $old_target = '/dev/null'; } else { $old_index = 'a/'.$old_path; $old_target = 'a/'.$old_path; } if ($cur_path === null) { $cur_index = 'b/'.$old_path; $cur_target = '/dev/null'; } else { $cur_index = 'b/'.$cur_path; $cur_target = 'b/'.$cur_path; } $result[] = "diff --git {$old_index} {$cur_index}".PHP_EOL; if ($type == ArcanistDiffChangeType::TYPE_ADD) { $result[] = "new file mode {$new_mode}".PHP_EOL; } if ($type == ArcanistDiffChangeType::TYPE_COPY_HERE || $type == ArcanistDiffChangeType::TYPE_MOVE_HERE || $type == ArcanistDiffChangeType::TYPE_COPY_AWAY) { if ($old_mode !== $new_mode) { $result[] = "old mode {$old_mode}".PHP_EOL; $result[] = "new mode {$new_mode}".PHP_EOL; } } if ($type == ArcanistDiffChangeType::TYPE_COPY_HERE) { $result[] = "copy from {$old_path}".PHP_EOL; $result[] = "copy to {$cur_path}".PHP_EOL; } else if ($type == ArcanistDiffChangeType::TYPE_MOVE_HERE) { $result[] = "rename from {$old_path}".PHP_EOL; $result[] = "rename to {$cur_path}".PHP_EOL; } else if ($type == ArcanistDiffChangeType::TYPE_DELETE || $type == ArcanistDiffChangeType::TYPE_MULTICOPY) { $old_mode = idx($change->getOldProperties(), 'unix:filemode'); if ($old_mode) { $result[] = "deleted file mode {$old_mode}".PHP_EOL; } } if ($change_body) { if (!$is_binary) { $result[] = "--- {$old_target}".PHP_EOL; $result[] = "+++ {$cur_target}".PHP_EOL; } $result[] = $change_body; } } $diff = implode('', $result).PHP_EOL; return $this->convertNonUTF8Diff($diff); } + private function isGitBinaryChange(ArcanistDiffChange $change) { + $file_type = $change->getFileType(); + return ($file_type == ArcanistDiffChangeType::FILE_BINARY || + $file_type == ArcanistDiffChangeType::FILE_IMAGE); + } + private function convertNonUTF8Diff($diff) { if ($this->encoding) { $diff = phutil_utf8_convert($diff, $this->encoding, 'UTF-8'); } return $diff; } public function getChanges() { return $this->changes; } private function breakHunkIntoSmallHunks(ArcanistDiffHunk $base_hunk) { $context = 3; $results = array(); $lines = phutil_split_lines($base_hunk->getCorpus()); $n = count($lines); $old_offset = $base_hunk->getOldOffset(); $new_offset = $base_hunk->getNewOffset(); $ii = 0; $jj = 0; while ($ii < $n) { // Skip lines until we find the next line with changes. Note: this skips // both ' ' (no changes) and '\' (no newline at end of file) lines. If we // don't skip the latter, we may incorrectly generate a terminal hunk // that has no actual change information when a file doesn't have a // terminal newline and not changed near the end of the file. 'patch' will // fail to apply the diff if we generate a hunk that does not actually // contain changes. for ($jj = $ii; $jj < $n; ++$jj) { $char = $lines[$jj][0]; if ($char == '-' || $char == '+') { break; } } if ($jj >= $n) { break; } $hunk_start = max($jj - $context, 0); // NOTE: There are two tricky considerations here. // We can not generate a patch with overlapping hunks, or 'git apply' // rejects it after 1.7.3.4. // We can not generate a patch with too much trailing context, or // 'patch' rejects it. // So we need to ensure that we generate disjoint hunks, but don't // generate any hunks with too much context. $old_lines = 0; $new_lines = 0; $hunk_adjust = 0; $last_change = $jj; $break_here = null; for (; $jj < $n; ++$jj) { if ($lines[$jj][0] == ' ') { if ($jj - $last_change > $context) { if ($break_here === null) { // We haven't seen a change in $context lines, so this is a // potential place to break the hunk. However, we need to keep // looking in case there is another change fewer than $context // lines away, in which case we have to merge the hunks. $break_here = $jj; } } if ($jj - $last_change > (($context + 1) * 2)) { // We definitely aren't going to merge this with the next hunk, so // break out of the loop. We'll end the hunk at $break_here. break; } } else { $break_here = null; $last_change = $jj; if ($lines[$jj][0] == '\\') { // When we have a "\ No newline at end of file" line, it does not // contribute to either hunk length. ++$hunk_adjust; } else if ($lines[$jj][0] == '-') { ++$old_lines; } else if ($lines[$jj][0] == '+') { ++$new_lines; } } } if ($break_here !== null) { $jj = $break_here; } $hunk_length = min($jj, $n) - $hunk_start; $count_length = ($hunk_length - $hunk_adjust); $hunk = new ArcanistDiffHunk(); $hunk->setOldOffset($old_offset + $hunk_start - $ii); $hunk->setNewOffset($new_offset + $hunk_start - $ii); $hunk->setOldLength($count_length - $new_lines); $hunk->setNewLength($count_length - $old_lines); $corpus = array_slice($lines, $hunk_start, $hunk_length); $corpus = implode('', $corpus); $hunk->setCorpus($corpus); $results[] = $hunk; $old_offset += ($jj - $ii) - $new_lines; $new_offset += ($jj - $ii) - $old_lines; $ii = $jj; } return $results; } private function getOldPath(ArcanistDiffChange $change) { $old_path = $change->getOldPath(); $type = $change->getType(); if (!strlen($old_path) || $type == ArcanistDiffChangeType::TYPE_ADD) { $old_path = null; } return $old_path; } private function getCurrentPath(ArcanistDiffChange $change) { $cur_path = $change->getCurrentPath(); $type = $change->getType(); if (!strlen($cur_path) || $type == ArcanistDiffChangeType::TYPE_DELETE || $type == ArcanistDiffChangeType::TYPE_MULTICOPY) { $cur_path = null; } return $cur_path; } private function buildHunkChanges(array $hunks) { assert_instances_of($hunks, 'ArcanistDiffHunk'); $result = array(); foreach ($hunks as $hunk) { $small_hunks = $this->breakHunkIntoSmallHunks($hunk); foreach ($small_hunks as $small_hunk) { $o_off = $small_hunk->getOldOffset(); $o_len = $small_hunk->getOldLength(); $n_off = $small_hunk->getNewOffset(); $n_len = $small_hunk->getNewLength(); $corpus = $small_hunk->getCorpus(); // NOTE: If the length is 1 it can be omitted. Since git does this, // we also do it so that "arc export --git" diffs are as similar to // real git diffs as possible, which helps debug issues. if ($o_len == 1) { $o_head = "{$o_off}"; } else { $o_head = "{$o_off},{$o_len}"; } if ($n_len == 1) { $n_head = "{$n_off}"; } else { $n_head = "{$n_off},{$n_len}"; } $result[] = "@@ -{$o_head} +{$n_head} @@".PHP_EOL; $result[] = $corpus; $last = substr($corpus, -1); if ($last !== false && $last != "\r" && $last != "\n") { $result[] = PHP_EOL; } } } return implode('', $result); } public function setLoadFileDataCallback($callback) { $this->loadFileDataCallback = $callback; return $this; } - private function getBlob($phid) { + private function getBlob($phid, $name = null) { if ($this->loadFileDataCallback) { return call_user_func($this->loadFileDataCallback, $phid); } if ($this->diskPath) { list($blob_data) = execx('tar xfO %s blobs/%s', $this->diskPath, $phid); return $blob_data; } + $console = PhutilConsole::getConsole(); + if ($this->conduit) { - echo "Downloading binary data...\n"; + if ($name) { + $console->writeErr("Downloading binary data for '%s'...\n", $name); + } else { + $console->writeErr("Downloading binary data...\n"); + } $data_base64 = $this->conduit->callMethodSynchronous( 'file.download', array( 'phid' => $phid, )); return base64_decode($data_base64); } throw new Exception("Nowhere to load blob '{$phid}' from!"); } private function buildBinaryChange(ArcanistDiffChange $change, $old_phid) { $old_phid = idx($change->getAllMetadata(), 'old:binary-phid', $old_phid); $new_phid = $change->getMetadata('new:binary-phid'); - if (!$old_phid) { + $old_data = null; + if ($change->getOriginalFileData() !== null) { + $old_data = $change->getOriginalFileData(); + } else if ($old_phid) { + $name = basename($change->getOldPath()); + $old_data = $this->getBlob($old_phid, $name); + } + + $old_length = strlen($old_data); + + if ($old_data === null) { $old_data = ''; - $old_length = 0; $old_sha1 = str_repeat('0', 40); } else { - $old_data = $this->getBlob($old_phid); - $old_length = strlen($old_data); $old_sha1 = sha1("blob {$old_length}\0{$old_data}"); } - if (!$new_phid) { + $new_data = null; + if ($change->getCurrentFileData() !== null) { + $new_data = $change->getCurrentFileData(); + } else if ($new_phid) { + $name = basename($change->getCurrentPath()); + $new_data = $this->getBlob($new_phid, $name); + } + + $new_length = strlen($new_data); + + if ($new_data === null) { $new_data = ''; - $new_length = 0; $new_sha1 = str_repeat('0', 40); } else { - $new_data = $this->getBlob($new_phid); - $new_length = strlen($new_data); $new_sha1 = sha1("blob {$new_length}\0{$new_data}"); } $content = array(); $content[] = "index {$old_sha1}..{$new_sha1}".PHP_EOL; $content[] = "GIT binary patch".PHP_EOL; $content[] = "literal {$new_length}".PHP_EOL; $content[] = $this->emitBinaryDiffBody($new_data).PHP_EOL; $content[] = "literal {$old_length}".PHP_EOL; $content[] = $this->emitBinaryDiffBody($old_data).PHP_EOL; return implode('', $content); } private function emitBinaryDiffBody($data) { if (!function_exists('gzcompress')) { throw new Exception( "This patch has binary data. The PHP zlib extension is required to ". "apply patches with binary data to git. Install the PHP zlib ". "extension to continue."); } // See emit_binary_diff_body() in diff.c for git's implementation. $buf = ''; $deflated = gzcompress($data); $lines = str_split($deflated, 52); foreach ($lines as $line) { $len = strlen($line); // The first character encodes the line length. if ($len <= 26) { $buf .= chr($len + ord('A') - 1); } else { $buf .= chr($len - 26 + ord('a') - 1); } $buf .= self::encodeBase85($line); $buf .= PHP_EOL; } return $buf; } public static function encodeBase85($data) { // This is implemented awkwardly in order to closely mirror git's // implementation in base85.c // It is also implemeted awkwardly to work correctly on 32-bit machines. // Broadly, this algorithm converts the binary input to printable output // by transforming each 4 binary bytes of input to 5 printable bytes of // output, one piece at a time. // // To do this, we convert the 4 bytes into a 32-bit integer, then use // modulus and division by 85 to pick out printable bytes (85^5 is slightly // larger than 2^32). In C, this algorithm is fairly easy to implement // because the accumulator can be made unsigned. // // In PHP, there are no unsigned integers, so values larger than 2^31 break // on 32-bit systems under modulus: // // $ php -r 'print (1 << 31) % 13;' # On a 32-bit machine. // -11 // // However, PHP's float type is an IEEE 754 64-bit double precision float, // so we can safely store integers up to around 2^53 without loss of // precision. To work around the lack of an unsigned type, we just use a // double and perform the modulus with fmod(). // // (Since PHP overflows integer operations into floats, we don't need much // additional casting.) static $map = array( '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '!', '#', '$', '%', '&', '(', ')', '*', '+', '-', ';', '<', '=', '>', '?', '@', '^', '_', '`', '{', '|', '}', '~', ); $buf = ''; $pos = 0; $bytes = strlen($data); while ($bytes) { $accum = 0; for ($count = 24; $count >= 0; $count -= 8) { $val = ord($data[$pos++]); $val = $val * (1 << $count); $accum = $accum + $val; if (--$bytes == 0) { break; } } $slice = ''; for ($count = 4; $count >= 0; $count--) { $val = (int)fmod($accum, 85.0); $accum = floor($accum / 85.0); $slice .= $map[$val]; } $buf .= strrev($slice); } return $buf; } } diff --git a/src/parser/ArcanistDiffParser.php b/src/parser/ArcanistDiffParser.php index ae582815..cfcdd2b7 100644 --- a/src/parser/ArcanistDiffParser.php +++ b/src/parser/ArcanistDiffParser.php @@ -1,1143 +1,1188 @@ api = $api; return $this; } protected function getRepositoryAPI() { return $this->api; } public function setDetectBinaryFiles($detect) { $this->detectBinaryFiles = $detect; return $this; } public function setTryEncoding($encoding) { $this->tryEncoding = $encoding; return $this; } public function forcePath($path) { $this->forcePath = $path; return $this; } public function setChanges(array $changes) { assert_instances_of($changes, 'ArcanistDiffChange'); $this->changes = mpull($changes, null, 'getCurrentPath'); return $this; } public function parseSubversionDiff(ArcanistSubversionAPI $api, $paths) { $this->setRepositoryAPI($api); $diffs = array(); foreach ($paths as $path => $status) { if ($status & ArcanistRepositoryAPI::FLAG_UNTRACKED || $status & ArcanistRepositoryAPI::FLAG_CONFLICT || $status & ArcanistRepositoryAPI::FLAG_MISSING) { unset($paths[$path]); } } $root = null; $from = array(); foreach ($paths as $path => $status) { $change = $this->buildChange($path); if ($status & ArcanistRepositoryAPI::FLAG_ADDED) { $change->setType(ArcanistDiffChangeType::TYPE_ADD); } else if ($status & ArcanistRepositoryAPI::FLAG_DELETED) { $change->setType(ArcanistDiffChangeType::TYPE_DELETE); } else { $change->setType(ArcanistDiffChangeType::TYPE_CHANGE); } $is_dir = is_dir($api->getPath($path)); if ($is_dir) { $change->setFileType(ArcanistDiffChangeType::FILE_DIRECTORY); // We have to go hit the diff even for directories because they may // have property changes or moves, etc. } $is_link = is_link($api->getPath($path)); if ($is_link) { $change->setFileType(ArcanistDiffChangeType::FILE_SYMLINK); } $diff = $api->getRawDiffText($path); if ($diff) { $this->parseDiff($diff); } $info = $api->getSVNInfo($path); if (idx($info, 'Copied From URL')) { if (!$root) { $rinfo = $api->getSVNInfo('.'); $root = $rinfo['URL'].'/'; } $cpath = $info['Copied From URL']; $cpath = substr($cpath, strlen($root)); if ($info['Copied From Rev']) { // The user can "svn cp /path/to/file@12345 x", which pulls a file out // of version history at a specific revision. If we just use the path, // we'll collide with possible changes to that path in the working // copy below. In particular, "svn cp"-ing a path which no longer // exists somewhere in the working copy and then adding that path // gets us to the "origin change type" branches below with a // TYPE_ADD state on the path. To avoid this, append the origin // revision to the path so we'll necessarily generate a new change. // TODO: In theory, you could have an '@' in your path and this could // cause a collision, e.g. two files named 'f' and 'f@12345'. This is // at least somewhat the user's fault, though. if ($info['Copied From Rev'] != $info['Revision']) { $cpath .= '@'.$info['Copied From Rev']; } } $change->setOldPath($cpath); $from[$path] = $cpath; } $type = $change->getType(); if (($type === ArcanistDiffChangeType::TYPE_MOVE_AWAY || $type === ArcanistDiffChangeType::TYPE_DELETE) && idx($info, 'Node Kind') === 'directory') { $change->setFileType(ArcanistDiffChangeType::FILE_DIRECTORY); } } foreach ($paths as $path => $status) { $change = $this->buildChange($path); if (empty($from[$path])) { continue; } if (empty($this->changes[$from[$path]])) { if ($change->getType() == ArcanistDiffChangeType::TYPE_COPY_HERE) { // If the origin path wasn't changed (or isn't included in this diff) // and we only copied it, don't generate a changeset for it. This // keeps us out of trouble when we go to 'arc commit' and need to // figure out which files should be included in the commit list. continue; } } $origin = $this->buildChange($from[$path]); $origin->addAwayPath($change->getCurrentPath()); $type = $origin->getType(); switch ($type) { case ArcanistDiffChangeType::TYPE_MULTICOPY: case ArcanistDiffChangeType::TYPE_COPY_AWAY: // "Add" is possible if you do some bizarre tricks with svn:ignore and // "svn copy"'ing URLs straight from the repository; you can end up with // a file that is a copy of itself. See T271. case ArcanistDiffChangeType::TYPE_ADD: break; case ArcanistDiffChangeType::TYPE_DELETE: $origin->setType(ArcanistDiffChangeType::TYPE_MOVE_AWAY); break; case ArcanistDiffChangeType::TYPE_MOVE_AWAY: $origin->setType(ArcanistDiffChangeType::TYPE_MULTICOPY); break; case ArcanistDiffChangeType::TYPE_CHANGE: $origin->setType(ArcanistDiffChangeType::TYPE_COPY_AWAY); break; default: throw new Exception("Bad origin state {$type}."); } $type = $origin->getType(); switch ($type) { case ArcanistDiffChangeType::TYPE_MULTICOPY: case ArcanistDiffChangeType::TYPE_MOVE_AWAY: $change->setType(ArcanistDiffChangeType::TYPE_MOVE_HERE); break; case ArcanistDiffChangeType::TYPE_ADD: case ArcanistDiffChangeType::TYPE_COPY_AWAY: $change->setType(ArcanistDiffChangeType::TYPE_COPY_HERE); break; default: throw new Exception("Bad origin state {$type}."); } } return $this->changes; } public function parseDiff($diff) { $this->didStartParse($diff); if ($this->getLine() === null) { $this->didFailParse("Can't parse an empty diff!"); } do { $patterns = array( // This is a normal SVN text change, probably from "svn diff". '(?PIndex): (?P.+)', // This is an SVN property change, probably from "svn diff". '(?PProperty changes on): (?P.+)', // This is a git commit message, probably from "git show". '(?Pcommit) (?P[a-f0-9]+)(?: \(.*\))?', // This is a git diff, probably from "git show" or "git diff". // Note that the filenames may appear quoted. '(?Pdiff --git) '. '(?P"?[abicwo12]/.+"?) '. '(?P"?[abicwo12]/.+"?)', // This is a unified diff, probably from "diff -u" or synthetic diffing. '(?P---) (?P.+)\s+\d{4}-\d{2}-\d{2}.*', '(?PBinary) files '. '(?P.+)\s+\d{4}-\d{2}-\d{2} and '. '(?P.+)\s+\d{4}-\d{2}-\d{2} differ.*', // This is a normal Mercurial text change, probably from "hg diff". It // may have two "-r" blocks if it came from "hg diff -r x:y". '(?Pdiff -r) (?P[a-f0-9]+) (?:-r [a-f0-9]+ )?(?P.+)', ); $line = $this->getLineTrimmed(); $match = null; $ok = $this->tryMatchHeader($patterns, $line, $match); $failed_parse = false; if (!$ok && $this->isFirstNonEmptyLine()) { // 'hg export' command creates so called "extended diff" that // contains some meta information and comment at the beginning // (isFirstNonEmptyLine() to check for beginning). Actual mercurial // code detects where comment ends and unified diff starts by // searching "diff -r" in the text. $this->saveLine(); $line = $this->nextLineThatLooksLikeDiffStart(); if (!$this->tryMatchHeader($patterns, $line, $match)) { // Restore line before guessing to display correct error. $this->restoreLine(); $failed_parse = true; } } else if (!$ok) { $failed_parse = true; } if ($failed_parse) { $this->didFailParse( "Expected a hunk header, like 'Index: /path/to/file.ext' (svn), ". "'Property changes on: /path/to/file.ext' (svn properties), ". "'commit 59bcc3ad6775562f845953cf01624225' (git show), ". "'diff --git' (git diff), '--- filename' (unified diff), or " . "'diff -r' (hg diff or patch)."); } if (isset($match['type'])) { if ($match['type'] == 'diff --git') { if (isset($match['old'])) { $match['old'] = $this->unescapeFilename($match['old']); $match['old'] = substr($match['old'], 2); } if (isset($match['cur'])) { $match['cur'] = $this->unescapeFilename($match['cur']); $match['cur'] = substr($match['cur'], 2); } } } $change = $this->buildChange(idx($match, 'cur')); if (isset($match['old'])) { $change->setOldPath($match['old']); } if (isset($match['hash'])) { $change->setCommitHash($match['hash']); } if (isset($match['binary'])) { $change->setFileType(ArcanistDiffChangeType::FILE_BINARY); $line = $this->nextNonemptyLine(); continue; } $line = $this->nextLine(); switch ($match['type']) { case 'Index': $this->parseIndexHunk($change); break; case 'Property changes on': $this->parsePropertyHunk($change); break; case 'diff --git': $this->setIsGit(true); $this->parseIndexHunk($change); break; case 'commit': $this->setIsGit(true); $this->parseCommitMessage($change); break; case '---': $ok = preg_match( '@^(?:\+\+\+) (.*)\s+\d{4}-\d{2}-\d{2}.*$@', $line, $match); if (!$ok) { $this->didFailParse("Expected '+++ filename' in unified diff."); } $change->setCurrentPath($match[1]); $line = $this->nextLine(); $this->parseChangeset($change); break; case 'diff -r': $this->setIsMercurial(true); $this->parseIndexHunk($change); break; default: $this->didFailParse("Unknown diff type."); break; } } while ($this->getLine() !== null); $this->didFinishParse(); return $this->changes; } protected function tryMatchHeader($patterns, $line, &$match) { foreach ($patterns as $pattern) { if (preg_match('@^'.$pattern.'$@', $line, $match)) { return true; } } return false; } protected function parseCommitMessage(ArcanistDiffChange $change) { $change->setType(ArcanistDiffChangeType::TYPE_MESSAGE); $message = array(); $line = $this->getLine(); if (preg_match('/^Merge: /', $line)) { $this->nextLine(); } $line = $this->getLine(); if (!preg_match('/^Author: /', $line)) { $this->didFailParse("Expected 'Author:'."); } $line = $this->nextLine(); if (!preg_match('/^Date: /', $line)) { $this->didFailParse("Expected 'Date:'."); } while (($line = $this->nextLineTrimmed()) !== null) { if (strlen($line) && $line[0] != ' ') { break; } // Strip leading spaces from Git commit messages. Note that empty lines // are represented as just "\n"; don't touch those. $message[] = preg_replace('/^ /', '', $this->getLine()); } $message = rtrim(implode('', $message), "\r\n"); $change->setMetadata('message', $message); } /** * Parse an SVN property change hunk. These hunks are ambiguous so just sort * of try to get it mostly right. It's entirely possible to foil this parser * (or any other parser) with a carefully constructed property change. */ protected function parsePropertyHunk(ArcanistDiffChange $change) { $line = $this->getLine(); if (!preg_match('/^_+$/', $line)) { $this->didFailParse("Expected '______________________'."); } $line = $this->nextLine(); while ($line !== null) { $done = preg_match('/^(Index|Property changes on):/', $line); if ($done) { break; } $matches = null; $ok = preg_match('/^(Modified|Added|Deleted): (.*)$/', $line, $matches); if (!$ok) { $this->didFailParse("Expected 'Added', 'Deleted', or 'Modified'."); } $op = $matches[1]; $prop = $matches[2]; list($old, $new) = $this->parseSVNPropertyChange($op, $prop); if ($old !== null) { $change->setOldProperty($prop, $old); } if ($new !== null) { $change->setNewProperty($prop, $new); } $line = $this->getLine(); } } private function parseSVNPropertyChange($op, $prop) { $old = array(); $new = array(); $target = null; $line = $this->nextLine(); $prop_index = 2; while ($line !== null) { $done = preg_match( '/^(Modified|Added|Deleted|Index|Property changes on):/', $line); if ($done) { break; } $trimline = ltrim($line); if ($trimline && $trimline[0] == '#') { // in svn1.7, a line like ## -0,0 +1 ## is put between the Added: line // and the line with the property change. If we have such a line, we'll // just ignore it (: $line = $this->nextLine(); $prop_index = 1; $trimline = ltrim($line); } if ($trimline && $trimline[0] == '+') { if ($op == 'Deleted') { $this->didFailParse('Unexpected "+" section in property deletion.'); } $target = 'new'; $line = substr($trimline, $prop_index); } else if ($trimline && $trimline[0] == '-') { if ($op == 'Added') { $this->didFailParse('Unexpected "-" section in property addition.'); } $target = 'old'; $line = substr($trimline, $prop_index); } else if (!strncmp($trimline, 'Merged', 6)) { if ($op == 'Added') { $target = 'new'; } else { // These can appear on merges. No idea how to interpret this (unclear // what the old / new values are) and it's of dubious usefulness so // just throw it away until someone complains. $target = null; } $line = $trimline; } if ($target == 'new') { $new[] = $line; } else if ($target == 'old') { $old[] = $line; } $line = $this->nextLine(); } $old = rtrim(implode('', $old)); $new = rtrim(implode('', $new)); if (!strlen($old)) { $old = null; } if (!strlen($new)) { $new = null; } return array($old, $new); } protected function setIsGit($git) { if ($this->isGit !== null && $this->isGit != $git) { throw new Exception("Git status has changed!"); } $this->isGit = $git; return $this; } protected function getIsGit() { return $this->isGit; } public function setIsMercurial($is_mercurial) { $this->isMercurial = $is_mercurial; return $this; } public function getIsMercurial() { return $this->isMercurial; } protected function parseIndexHunk(ArcanistDiffChange $change) { $is_git = $this->getIsGit(); $is_mercurial = $this->getIsMercurial(); $is_svn = (!$is_git && !$is_mercurial); $move_source = null; $line = $this->getLine(); if ($is_git) { do { $patterns = array( '(?Pnew) file mode (?P\d+)', '(?Pdeleted) file mode (?P\d+)', // These occur when someone uses `chmod` on a file. 'old mode (?P\d+)', 'new mode (?P\d+)', // These occur when you `mv` a file and git figures it out. 'similarity index ', 'rename from (?P.*)', '(?Prename) to (?P.*)', 'copy from (?P.*)', '(?Pcopy) to (?P.*)' ); $ok = false; $match = null; foreach ($patterns as $pattern) { $ok = preg_match('@^'.$pattern.'@', $line, $match); if ($ok) { break; } } if (!$ok) { if ($line === null || preg_match('/^(diff --git|commit) /', $line)) { // In this case, there are ONLY file mode changes, or this is a // pure move. If it's a move, flag these changesets so we can build // synthetic changes later, enabling us to show file contents in // Differential -- git only gives us a block like this: // // diff --git a/README b/READYOU // similarity index 100% // rename from README // rename to READYOU // // ...i.e., there is no associated diff. $change->setNeedsSyntheticGitHunks(true); if ($move_source) { $move_source->setNeedsSyntheticGitHunks(true); } return; } break; } if (!empty($match['oldmode'])) { $change->setOldProperty('unix:filemode', $match['oldmode']); } if (!empty($match['newmode'])) { $change->setNewProperty('unix:filemode', $match['newmode']); } if (!empty($match['deleted'])) { $change->setType(ArcanistDiffChangeType::TYPE_DELETE); } if (!empty($match['new'])) { // If you replace a symlink with a normal file, git renders the change // as a "delete" of the symlink plus an "add" of the new file. We // prefer to represent this as a change. if ($change->getType() == ArcanistDiffChangeType::TYPE_DELETE) { $change->setType(ArcanistDiffChangeType::TYPE_CHANGE); } else { $change->setType(ArcanistDiffChangeType::TYPE_ADD); } } if (!empty($match['old'])) { $match['old'] = $this->unescapeFilename($match['old']); $change->setOldPath($match['old']); } if (!empty($match['cur'])) { $match['cur'] = $this->unescapeFilename($match['cur']); $change->setCurrentPath($match['cur']); } if (!empty($match['copy'])) { $change->setType(ArcanistDiffChangeType::TYPE_COPY_HERE); $old = $this->buildChange($change->getOldPath()); $type = $old->getType(); if ($type == ArcanistDiffChangeType::TYPE_MOVE_AWAY) { $old->setType(ArcanistDiffChangeType::TYPE_MULTICOPY); } else { $old->setType(ArcanistDiffChangeType::TYPE_COPY_AWAY); } $old->addAwayPath($change->getCurrentPath()); } if (!empty($match['move'])) { $change->setType(ArcanistDiffChangeType::TYPE_MOVE_HERE); $old = $this->buildChange($change->getOldPath()); $type = $old->getType(); if ($type == ArcanistDiffChangeType::TYPE_MULTICOPY) { // Great, no change. } else if ($type == ArcanistDiffChangeType::TYPE_MOVE_AWAY) { $old->setType(ArcanistDiffChangeType::TYPE_MULTICOPY); } else if ($type == ArcanistDiffChangeType::TYPE_COPY_AWAY) { $old->setType(ArcanistDiffChangeType::TYPE_MULTICOPY); } else { $old->setType(ArcanistDiffChangeType::TYPE_MOVE_AWAY); } // We'll reference this above. $move_source = $old; $old->addAwayPath($change->getCurrentPath()); } $line = $this->nextNonemptyLine(); } while (true); } $line = $this->getLine(); if ($is_svn) { $ok = preg_match('/^=+$/', $line); if (!$ok) { $this->didFailParse("Expected '=======================' divider line."); } else { // Adding an empty file in SVN can produce an empty line here. $line = $this->nextNonemptyLine(); } } else if ($is_git) { $ok = preg_match('/^index .*$/', $line); if (!$ok) { // TODO: "hg diff -g" diffs ("mercurial git-style diffs") do not include // this line, so we can't parse them if we fail on it. Maybe introduce // a flag saying "parse this diff using relaxed git-style diff rules"? // $this->didFailParse("Expected 'index af23f...a98bc' header line."); } else { // NOTE: In the git case, where this patch is the last change in the // file, we may have a final terminal newline. Skip over it so that // we'll hit the '$line === null' block below. This is covered by the // 'git-empty-file.gitdiff' test case. $line = $this->nextNonemptyLine(); } } // If there are files with only whitespace changes and -b or -w are // supplied as command-line flags to `diff', svn and git both produce // changes without any body. if ($line === null || preg_match( '/^(Index:|Property changes on:|diff --git|commit) /', $line)) { return; } $is_binary_add = preg_match( '/^Cannot display: file marked as a binary type.$/', $line); if ($is_binary_add) { $this->nextLine(); // Cannot display: file marked as a binary type. $this->nextNonemptyLine(); // svn:mime-type = application/octet-stream $this->markBinary($change); return; } // We can get this in git, or in SVN when a file exists in the repository // WITHOUT a binary mime-type and is changed and given a binary mime-type. $is_binary_diff = preg_match( '/^Binary files .* and .* differ$/', $line); if ($is_binary_diff) { $this->nextNonemptyLine(); // Binary files x and y differ $this->markBinary($change); return; } // This occurs under "hg diff --git" when a binary file is removed. See // test case "hg-binary-delete.hgdiff". (I believe it never occurs under // git, which reports the "files X and /dev/null differ" string above. Git // can not apply these patches.) $is_hg_binary_delete = preg_match( '/^Binary file .* has changed$/', $line); if ($is_hg_binary_delete) { $this->nextNonemptyLine(); $this->markBinary($change); return; } // With "git diff --binary" (not a normal mode, but one users may explicitly // invoke and then, e.g., copy-paste into the web console) or "hg diff // --git" (normal under hg workflows), we may encounter a literal binary // patch. $is_git_binary_patch = preg_match( '/^GIT binary patch$/', $line); if ($is_git_binary_patch) { $this->nextLine(); $this->parseGitBinaryPatch(); $line = $this->getLine(); if (preg_match('/^literal/', $line)) { // We may have old/new binaries (change) or just a new binary (hg add). // If there are two blocks, parse both. $this->parseGitBinaryPatch(); } $this->markBinary($change); return; } if ($is_git) { // "git diff -b" ignores whitespace, but has an empty hunk target if (preg_match('@^diff --git a/.*$@', $line)) { $this->nextLine(); return null; } } $old_file = $this->parseHunkTarget(); $new_file = $this->parseHunkTarget(); $change->setOldPath($old_file); $this->parseChangeset($change); } private function parseGitBinaryPatch() { // TODO: We could decode the patches, but it's a giant mess so don't bother // for now. We'll pick up the data from the working copy in the common // case ("arc diff"). $line = $this->getLine(); if (!preg_match('/^literal /', $line)) { $this->didFailParse("Expected 'literal NNNN' to start git binary patch."); } do { $line = $this->nextLineTrimmed(); if ($line === '' || $line === null) { // Some versions of Mercurial apparently omit the terminal newline, // although it's unclear if Git will ever do this. In either case, // rely on the base85 check for sanity. $this->nextNonemptyLine(); return; } else if (!preg_match('/^[a-zA-Z]/', $line)) { $this->didFailParse("Expected base85 line length character (a-zA-Z)."); } } while (true); } protected function parseHunkTarget() { $line = $this->getLine(); $matches = null; $remainder = '(?:\s*\(.*\))?'; if ($this->getIsMercurial()) { // Something like "Fri Aug 26 01:20:50 2005 -0700", don't bother trying // to parse it. $remainder = '\t.*'; } $ok = preg_match( '@^[-+]{3} (?:[ab]/)?(?P.*?)'.$remainder.'$@', $line, $matches); if (!$ok) { $this->didFailParse( "Expected hunk target '+++ path/to/file.ext (revision N)'."); } $this->nextLine(); return $matches['path']; } protected function markBinary(ArcanistDiffChange $change) { $change->setFileType(ArcanistDiffChangeType::FILE_BINARY); return $this; } protected function parseChangeset(ArcanistDiffChange $change) { $all_changes = array(); do { $hunk = new ArcanistDiffHunk(); $line = $this->getLineTrimmed(); $real = array(); // In the case where only one line is changed, the length is omitted. // The final group is for git, which appends a guess at the function // context to the diff. $matches = null; $ok = preg_match( '/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: .*?)?$/U', $line, $matches); if (!$ok) { // It's possible we hit the style of an svn1.7 property change. // This is a 4-line Index block, followed by an empty line, followed // by a "Property changes on:" section similar to svn1.6. if ($line == '') { $line = $this->nextNonemptyLine(); $ok = preg_match('/^Property changes on:/', $line); if (!$ok) { $this->didFailParse("Confused by empty line"); } $line = $this->nextLine(); return $this->parsePropertyHunk($change); } $this->didFailParse("Expected hunk header '@@ -NN,NN +NN,NN @@'."); } $hunk->setOldOffset($matches[1]); $hunk->setNewOffset($matches[3]); // Cover for the cases where length wasn't present (implying one line). $old_len = idx($matches, 2); if (!strlen($old_len)) { $old_len = 1; } $new_len = idx($matches, 4); if (!strlen($new_len)) { $new_len = 1; } $hunk->setOldLength($old_len); $hunk->setNewLength($new_len); $add = 0; $del = 0; $advance = false; while ((($line = $this->nextLine()) !== null)) { if (strlen($line)) { $char = $line[0]; } else { $char = '~'; } switch ($char) { case '\\': if (!preg_match('@\\ No newline at end of file@', $line)) { $this->didFailParse( "Expected '\ No newline at end of file'."); } if ($new_len) { $real[] = $line; $hunk->setIsMissingOldNewline(true); } else { $real[] = $line; $hunk->setIsMissingNewNewline(true); } if (!$new_len) { $advance = true; break 2; } break; case '+': if (!$new_len) { break 2; } ++$add; --$new_len; $real[] = $line; break; case '-': if (!$old_len) { break 2; } ++$del; --$old_len; $real[] = $line; break; case ' ': if (!$old_len && !$new_len) { break 2; } --$old_len; --$new_len; $real[] = $line; break; case "\r": case "\n": case '~': $advance = true; break 2; default: break 2; } } if ($old_len != 0 || $new_len != 0) { $this->didFailParse("Found the wrong number of hunk lines."); } $corpus = implode('', $real); $is_binary = false; if ($this->detectBinaryFiles) { $is_binary = !phutil_is_utf8($corpus); $try_encoding = $this->tryEncoding; if ($is_binary && $try_encoding) { $is_binary = ArcanistDiffUtils::isHeuristicBinaryFile($corpus); if (!$is_binary) { $corpus = phutil_utf8_convert($corpus, 'UTF-8', $try_encoding); if (!phutil_is_utf8($corpus)) { throw new Exception( "Failed to convert a hunk from '{$try_encoding}' to UTF-8. ". "Check that the specified encoding is correct."); } } } } if ($is_binary) { // SVN happily treats binary files which aren't marked with the right // mime type as text files. Detect that junk here and mark the file // binary. We'll catch stuff with unicode too, but that's verboten // anyway. If there are too many false positives with this we might // need to make it threshold-triggered instead of triggering on any // unprintable byte. $change->setFileType(ArcanistDiffChangeType::FILE_BINARY); } else { $hunk->setCorpus($corpus); $hunk->setAddLines($add); $hunk->setDelLines($del); $change->addHunk($hunk); } if ($advance) { $line = $this->nextNonemptyLine(); } } while (preg_match('/^@@ /', $line)); } protected function buildChange($path = null) { $change = null; if ($path !== null) { if (!empty($this->changes[$path])) { return $this->changes[$path]; } } if ($this->forcePath) { return $this->changes[$this->forcePath]; } $change = new ArcanistDiffChange(); if ($path !== null) { $change->setCurrentPath($path); $this->changes[$path] = $change; } else { $this->changes[] = $change; } return $change; } protected function didStartParse($text) { $this->rawDiff = $text; // Eat leading whitespace. This may happen if the first change in the diff // is an SVN property change. $text = ltrim($text); // Try to strip ANSI color codes from colorized diffs. ANSI color codes // might be present in two cases: // // - You piped a colorized diff into 'arc --raw' or similar (normally // we're able to disable colorization on diffs we control the generation // of). // - You're diffing a file which actually contains ANSI color codes. // // The former is vastly more likely, but we try to distinguish between the // two cases by testing for a color code at the beginning of a line. If // we find one, we know it's a colorized diff (since the beginning of the // line should be "+", "-" or " " if the code is in the diff text). // // While it's possible a diff might be colorized and fail this test, it's // unlikely, and it covers hg's color extension which seems to be the most // stubborn about colorizing text despite stdout not being a TTY. // // We might incorrectly strip color codes from a colorized diff of a text // file with color codes inside it, but this case is stupid and pathological // and you've dug your own grave. $ansi_color_pattern = '\x1B\[[\d;]*m'; if (preg_match('/^'.$ansi_color_pattern.'/m', $text)) { $text = preg_replace('/'.$ansi_color_pattern.'/', '', $text); } $this->text = phutil_split_lines($text); $this->line = 0; } protected function getLine() { if ($this->text === null) { throw new Exception("Not parsing!"); } if (isset($this->text[$this->line])) { return $this->text[$this->line]; } return null; } protected function getLineTrimmed() { $line = $this->getLine(); if ($line !== null) { $line = trim($line, "\r\n"); } return $line; } protected function nextLine() { $this->line++; return $this->getLine(); } protected function nextLineTrimmed() { $line = $this->nextLine(); if ($line !== null) { $line = trim($line, "\r\n"); } return $line; } protected function nextNonemptyLine() { while (($line = $this->nextLine()) !== null) { if (strlen(trim($line)) !== 0) { break; } } return $this->getLine(); } protected function nextLineThatLooksLikeDiffStart() { while (($line = $this->nextLine()) !== null) { if (preg_match('/^\s*diff\s+-r/', $line)) { break; } } return $this->getLine(); } protected function saveLine() { $this->lineSaved = $this->line; } protected function restoreLine() { $this->line = $this->lineSaved; } protected function isFirstNonEmptyLine() { $count = count($this->text); for ($i = 0; $i < $count; $i++) { if (strlen(trim($this->text[$i])) != 0) { return ($i == $this->line); } } // Entire file is empty. return false; } protected function didFinishParse() { $this->text = null; } public function setWriteDiffOnFailure($write) { $this->writeDiffOnFailure = $write; return $this; } protected function didFailParse($message) { $context = 5; $min = max(0, $this->line - $context); $max = min($this->line + $context, count($this->text) - 1); $context = ''; for ($ii = $min; $ii <= $max; $ii++) { $context .= sprintf( "%8.8s %6.6s %s", ($ii == $this->line) ? '>>> ' : '', $ii + 1, $this->text[$ii]); } $out = array(); $out[] = "Diff Parse Exception: {$message}"; if ($this->writeDiffOnFailure) { $temp = new TempFile(); $temp->setPreserveFile(true); Filesystem::writeFile($temp, $this->rawDiff); $out[] = "Raw input file was written to: ".(string)$temp; } $out[] = $context; $out = implode("\n\n", $out); throw new Exception($out); } /** * Unescape escaped filenames, e.g. from "git diff". */ private function unescapeFilename($name) { if (preg_match('/^".+"$/', $name)) { return stripcslashes(substr($name, 1, -1)); } else { return $name; } } + + public function loadSyntheticData( + array $changes, + ArcanistRepositoryAPI $repository_api) { + assert_instances_of($changes, 'ArcanistDiffChange'); + + foreach ($changes as $change) { + $path = $change->getCurrentPath(); + + // Certain types of changes (moves and copies) don't contain change data + // when expressed in raw "git diff" form. Augment any such diffs with + // textual data. + if ($change->getNeedsSyntheticGitHunks()) { + $diff = $repository_api->getRawDiffText($path, $moves = false); + + $raw_changes = $this->parseDiff($diff); + foreach ($raw_changes as $raw_change) { + if ($raw_change->getCurrentPath() == $path) { + $change->setFileType($raw_change->getFileType()); + foreach ($raw_change->getHunks() as $hunk) { + // Git thinks that this file has been added. But we know that it + // has been moved or copied without a change. + $hunk->setCorpus( + preg_replace('/^\+/m', ' ', $hunk->getCorpus())); + $change->addHunk($hunk); + } + break; + } + } + + $change->setNeedsSyntheticGitHunks(false); + } + + if ($change->getFileType() != ArcanistDiffChangeType::FILE_BINARY && + $change->getFileType() != ArcanistDiffChangeType::FILE_IMAGE) { + continue; + } + + $change->setOriginalFileData($repository_api->getOriginalFileData($path)); + $change->setCurrentFileData($repository_api->getCurrentFileData($path)); + } + + return $changes; + } + } diff --git a/src/parser/__tests__/ArcanistBundleTestCase.php b/src/parser/__tests__/ArcanistBundleTestCase.php index cb52dd68..45837687 100644 --- a/src/parser/__tests__/ArcanistBundleTestCase.php +++ b/src/parser/__tests__/ArcanistBundleTestCase.php @@ -1,432 +1,442 @@ getResourcePath($name)); } private function getResourcePath($name) { return dirname(__FILE__).'/bundle/'.$name; } private function loadDiff($old, $new) { list($err, $stdout) = exec_manual( 'diff --unified=65535 --label %s --label %s -- %s %s', 'file 9999-99-99', 'file 9999-99-99', $this->getResourcePath($old), $this->getResourcePath($new)); $this->assertEqual( 1, $err, "Expect `diff` to find changes between '{$old}' and '{$new}'."); return $stdout; } private function loadOneChangeBundle($old, $new) { $diff = $this->loadDiff($old, $new); return ArcanistBundle::newFromDiff($diff); } /** * Unarchive a saved git repository and apply each commit as though via * "arc patch", verifying that the resulting tree hash is identical to the * tree hash produced by the real commit. */ public function testGitRepository() { if (phutil_is_windows()) { $this->assertSkipped('This test is not supported under Windows.'); } $archive = dirname(__FILE__).'/bundle.git.tgz'; $fixture = PhutilDirectoryFixture::newFromArchive($archive); chdir($fixture->getPath()); list($commits) = execx( 'git log --format=%s', '%H %T %s'); $commits = explode("\n", trim($commits)); // The very first commit doesn't have a meaningful parent, so don't examine // it. array_pop($commits); foreach ($commits as $commit) { list($commit_hash, $tree_hash, $subject) = explode(' ', $commit, 3); + execx('git reset --hard %s --', $commit_hash); + list($diff) = execx( 'git diff %s^ %s --', $commit_hash, $commit_hash); $parser = new ArcanistDiffParser(); $changes = $parser->parseDiff($diff); + + $repository_api = new ArcanistGitAPI($fixture->getPath()); + $repository_api->setRelativeCommit($commit_hash.'^'); + + $parser->loadSyntheticData($changes, $repository_api); + $bundle = ArcanistBundle::newFromChanges($changes); execx('git reset --hard %s^ --', $commit_hash); + $patch = $bundle->toGitPatch(); + id(new ExecFuture('git apply --index --reject')) - ->write($bundle->toGitPatch()) + ->write($patch) ->resolvex(); execx('git commit -m %s', $subject); list($result_hash) = execx('git log -n1 --format=%s', '%T'); $result_hash = trim($result_hash); $this->assertEqual( $tree_hash, $result_hash, "Commit {$commit_hash}: {$subject}"); } } public function testTrailingContext() { // Diffs need to generate without extra trailing context, or 'patch' will // choke on them. $this->assertEqual( $this->loadResource('trailing-context.diff'), $this->loadOneChangeBundle( 'trailing-context.old', 'trailing-context.new')->toUnifiedDiff()); } public function testDisjointHunks() { // Diffs need to generate without overlapping hunks. $this->assertEqual( $this->loadResource('disjoint-hunks.diff'), $this->loadOneChangeBundle( 'disjoint-hunks.old', 'disjoint-hunks.new')->toUnifiedDiff()); } public function testNonlocalTrailingNewline() { // Diffs without changes near the end of the file should not generate a // bogus, change-free hunk if the file has no trailing newline. $this->assertEqual( $this->loadResource('trailing-newline.diff'), $this->loadOneChangeBundle( 'trailing-newline.old', 'trailing-newline.new')->toUnifiedDiff()); } public function testEncodeBase85() { $data = ''; for ($ii = 0; $ii <= 255; $ii++) { $data .= chr($ii); } for ($ii = 255; $ii >= 0; $ii--) { $data .= chr($ii); } $expect = Filesystem::readFile(dirname(__FILE__).'/base85/expect1.txt'); $expect = trim($expect); $this->assertEqual( $expect, ArcanistBundle::encodeBase85($data)); // This is just a large block of random binary data, it has no special // significance. $data = "\x56\x4c\xb3\x63\xe5\x4a\x9f\x03\xa3\x4c\xdd\x5d\x85\x86\x10". "\x30\x3f\xc1\x28\x51\xd8\xb2\x1a\xc3\x79\x15\x85\x31\x66\xf9". "\x8e\xe1\x20\x8f\x12\xa1\x94\x0e\xbf\xb6\x9c\xb5\xc0\x15\x43". "\x3d\xad\xed\x00\x3c\x16\xfa\x76\x2f\xed\x99\x3a\x78\x3e\xd1". "\x91\xf8\xb0\xca\xb9\x29\xfe\xd4\x0f\x16\x70\x19\xad\xd9\x42". "\x15\xb4\x8f\xd6\x8f\x80\x62\xe9\x48\x77\x9f\x38\x6d\x3f\xd6". "\x0e\x40\x68\x68\x93\xae\x75\x6d\x7f\x75\x9c\x80\x69\x94\x22". "\x87\xb6\xc0\x62\x6b\xab\x49\xb8\x91\xe9\x96\xbf\x04\xc2\x50". "\x30\xae\xea\xc1\x70\x8e\x91\xd0\xb6\xec\x56\x14\x78\xd5\x8a". "\x8c\x52\xd1\x3c\xde\x65\x21\xec\x93\xab\xcf\x7e\xf5\xfd\x6d". "\x2d\x69\xb9\x2e\xa3\x42\x7b\x4d\xa5\xfb\x28\x6d\x74\xa3\x7b". "\x3a\xc5\x34\x7c\x63\xa9\xf9\x8e\x34\x14\x42\xb0\xf1\x0e\xe2". "\xd0\xd2\x04\x81\xff\x62\xd5\xd9\x46\x3b\x36\x88\x8a\x93\x55". "\x02\x2c\xff\x9f\x48\xd6\x7a\xcb\xbf\x6a\x33\xaa\x6b\x08\x4c". "\x96\x98\x89\x53\x56\xb4\xb3\x9b\x06\xb1\xa0\x13\x69\xfa\x6a". "\xa8\x0d\x6a\xda\xb2\x6f\x62\x0b\xa8\xf6\x59\x29\x46\x7d\x04". "\x44\xeb\x90\x6f\xd7\xc7\xb6\xca\xc5\xeb\xde\x10\x9b\xbd\xf2". "\x66\x8e\xd0\x0b\xda\x8c\xeb\x90\x73\x73\x33\xe7\x6f\x26\x57". "\x4e\xfc\x95\xe0\xfc\x62\x93\xa7\x28\xe6\x0c\x46\x73\xdd\x01". "\xce\x43\x9b\x4e\x16\x74\x5b\x36\x92\x5a\x66\x4c\xe3\x9e\x90". "\x2d\x9a\x1a\x3d\x69\x39\x67\x04\xd6\xf8\x5f\x45\xee\xbb\xd4". "\x63\xcf\x8c\x9b\x31\x69\x98\x1a\x98\x57\x4b\xa9\x49\xf6\x1b". "\x76\x28\xd7\xe3\x8f\x63\x95\x5b\x06\xe2\xa8\x66\x60\xf9\x49". "\x4e\x40\x53\x32\x9b\x74\x36\xc0\x56\xf4\x33\xec\x83\xd2\x2c". "\x69\x60\x55\x11\x3b\x4f\xd6\x0a\xf6\x04\x38\x75\xb6\xc2\x82". "\x4d\xfa\x83\x56\xba\x35\x42\xc3\xcb\xdc\x28\xf4\x69\x48\xa9". "\xe0\x51\x41\x79\x66\xfe\x61\xd1\xf2\x9f\x7b\xde\xc4\x3e\x8f". "\x8f\xb6\x9c\x0a\x74\xf8\x71\x03\x37\x37\x30\x8d\x2a\x6a\xc9". "\x51\xa1\xe2\x34\xe5\x42\xdb\x4f\x61\x4e\x16\xfc\x23\x72\x12". "\x46\x53\x12\x82\x3e\x44\x63\x23\x82\xaa\xab\x7e\x8d\x70\x66". "\xf1\x94\x86\x02\xc5\x3e\x9c\x79\x17\x1e\x9f\x13\x89\x3d\x25". "\x45\xc9\x3b\x1e\xa0\x1a\x03\x20\x1c\x81\x6b\xfc\xb5\xc9\xe2". "\xda\xb1\x87\x34\xa0\xb2\x72\x36\x68\x12\x05\x53\x7c\x68\x6b". "\x1e\x2a\x56\x2a\x7e\x7f\xd0\x9c\x13\xa9\xb2\x4c\xe6\x8a\x65". "\xd7\x67\xad\xf3\xf3\x2b\x9c\xe8\x10\x07\x8a\xe2\x20\x67\xe4". "\x51\x47\xc1\x22\x91\x05\x22\x39\x1a\xef\x54\xd2\x8a\x88\x55". "\x3f\x83\xba\x73\xd4\x95\xc7\xb8\xa2\xfd\x4d\x4e\x5d\xff\xdd". "\xaf\x1a\xc2\x7e\xb5\xfa\x86\x5f\x93\x38\x5d\xca\x9a\x5a\x7e". "\xb7\x47\xd5\x5c\x6b\xf3\x32\x03\x11\x44\xe9\x49\x12\x40\x82". "\x67\x7d\x2a\x5a\x61\x81\xbd\x24\xaa\xd7\x7c\xc9\xcf\xaf\xb0". "\x3e\xb0\x43\xcd\xce\x21\xe4\x1b\x5a\xd6\x40\xf5\x0e\x23\xef". "\x70\xf4\xc6\xd2\xd7\x36\xd7\x20\xda\x8d\x39\x46\xea\xfc\x78". "\x55\xa2\x02\xd6\x77\x21\xc8\x97\x1e\xdf\x45\xde\x93\xa7\x74". "\xd8\x59\x10\x24\x8a\xe8\xcd\xe9\x00\xb5\x4e\xe6\x49\xb0\xde". "\x14\x1a\x5d\xdd\x38\x47\xb0\xc7\x1e\xec\x7c\x76\xc9\x21\x3c". "\x3a\x85\x4f\x71\x97\xed\x4a\x94\x2c\x51\x48\x9c\x43\x90\x70". "\xe9\x0e\x84\x55\xd2\xa4\x48\xfa\xfd\x54\x12\x11\xb9\x32\xfc". "\x1d\x66\xe7\x42\xe3\x5e\x65\xf4\x3d\xea\x1a\x53\xe3\x7b\x4b". "\xee\xdb\x74\xce\x30\xd3\x04\xcb\xda\xa4\xdd\xad\x98\x3a\x76". "\xe8\xba\x1b\x03\x53\xed\x46\x5d\xef\xd4\x34\xc2\x8d\xef\xae". "\x51\x35\x0f\x4d\x40\xaa\x3a\xdb\x50\x1a\xbe\x5f\x8b\xb8\x24". "\x40\x19\x8f\x8a\x6b\x44\x4f\x9b\xe0\xf4\x9c\x4b\xc4\x23\x37". "\xf0\xb3\xe1\x58\x9d\x0e\xd9\xa9\xf7\x3e\x86\x43\x9b\x5b\x90". "\x3c\xc0\x20\xa0\xc5\x86\x4f\xc6\xcb\xb5\xcb\xd4\x88\xc6\x72". "\x57\xa7\x57\x2c\x34\x26\x91\x44\x15\xa8\xf4\x88\xca\x74\x56". "\x9e\x12\x6c\xdf\x52\xef\xc0\xb4\x5c\x16\xe8\xaa\xf7\xb6\xf3". "\x7c\xda\xcd\x42\xf9\x1c\x40\x88\x44\x68\x4f\x1b\x5a\x7b\x8f". "\xc3\x47\x48\xd3\xf3\xe5\xf5\x66\x35\x48\xbe\x64\xdf\xfe\x35". "\xf1\xc3\xe4\xa8\xfc\x86\xfb\x69\x20\xc9\xf4\x16\x96\xc1\x7a". "\x51\x14\x77\xa4\x6e\x13\xe8\x59\x35\x24\xf1\xe5\xfe\xe9\x98". "\x0d\xd1\xe8\xce\x9c\x7f\xf8\x3b\x79\x39\x3a\x1d\xa3\x77\xef". "\x4f\x4b\x59\x73\x03\xb3\xfe\xae\x70\x2a\x3a\xf0\x79\x9d\x7e". "\x9b\xaa\xb1\x18\xf9\x43\x69\xf3\x55\x46\xad\x38\xa2\xf1\xcb". "\xce\x37\xa9\x88\x20\x38\xea\x19\x29\x95\x8c\x75\x06\x9d\x1d". "\x9e\xf2\xb7\x64\x98\x21\x36\x90\x92\xf8\xb8\x89\x1e\x5c\x5d". "\x09\x3b\x52\xc5\x6a\x87\x7e\x46\xca\x8c\xdf\xe7\xca\xa9\x7b". "\x11\x63\x0f\x9e\x42\x9a\x3e\xe0\x8b\x80\x9e\x91\x76\x88\x9a". "\xa1\xe2\x96\xae\xfb\x18\x39\xdc\x92\x99\x34\xfd\x98\x20\xa8". "\x89\x61\x2c\x26\xe0\xb8\x83\xa7\xe7\x50\x42\x8f\xfc\x36\x66". "\x6b\x25\xc5\x6d\xb4\x31\xe1\x4d\x0f\x2e\xf8\x44\xe2\xb6\x6a". "\x6d\xfe\x83\x9e\x2c\x07\x2f\x15\x41\xf3\xe7\xa6\x18\x2b\x84". "\x7e\xeb\x43\xcc\xbb\xdb\xa9\x54\x5c\xbc\x59\x6a\xdc\x26\x2a". "\xf4\x59\xa7\x75\xa4\xac\xed\x73\x8f\x16\x43\x0d\x97\x10\x2c". "\x70\xef\x9e\xb2\xc9\xdf\xe6\xa7\x9b\x08\x79\xa3\xf7\x99\xf5". "\x59\xe4\xd5\x89\x10\xe5\xc9\xf7\xe7\x29\x72\x06\xc6\x54\xc3". "\xcd\xd0\xff\x69\xf8\xdf\x19\xf2\x66\x1c\x69\x40\xbc\x97\xf1". "\x49\x5e\x78\x62\x52\x46\x7f\xcf\x44\x50\x8b\x5f\xe7\xa8\xeb". "\xd5\x84\x24\x81\xc0\x2c\x65\xf7\x95\xbd\xf2\x8e\x43\xfb\x6a". "\x49\x3c\x6a\xe5\x2a\x39\xf0\xfa\x89\x59\x5f\x39\x75\xb4\x6f". "\x04\xf1\xe0\x2c\xcd\x77\x34\xec\x6b\x45\x16\xe3\x18\x24\x05". "\xb9\x68\xc1\x4e\x71\x4b\xff\x88\x18\xea\x0d\x56\x49\x55\xdf". "\xe5\xb0\x59\xdb\x74\x9e\x0b\x38\x03\x9f\x10\x6f\xd9\x34\x07". "\x44\x29\x08\xb1\xd4\x77\xc6\x84\x0d\xbb\xb5\xd5\x09\x05\x19". "\x01\x62\x29\x45\x52\x1d\xc6\x4f\x25\x78\x7e\xbc\xae\x07\xb3". "\xd4\xe0\x19\x91\x03\xd6\x8d\x2f\x00\xc9\xb2\x66\x3b\x4e\x3d". "\x75\xf7\x23\x9a\x3e\xa4\xd5\x7f\x75\x47\xd0\xbc\xc3\xc8\x2a". "\xdc\x85\x09\x6c\x0c\x90\x38\xd8\xef\xcf\xf4\x7a\x1b\xc7\x76". "\xe0\xdb\x81\xa8\x1b\x2b\x8d\xd4\x36\x90\x76\xde\x8a\x90\xc8". "\x5b\x05\x00\xeb\xb3\x20\xce\x6e\x5c\xb9\x35\x3d\x95\x3a\x79". "\x4a\x60\xeb\x23\x11\xfb\x90\x2d\xf6\xb7\x05\x4a\x43\x41\x79". "\x51\xaa\xe6\x90\x0a\x71\x87\x80\xbe\xb0\x89\x0f\xd3\x84\x19". "\xce\x6c\xf9\xbb\x1b\x15\x4d\x0f\x33\x65\xf7\x9e\x3a\xd9\x8c". "\x02\x43\xcf\xdf\xb2\x60\xc1\x4c\xe9\xa5\x3c\xaf\xfa\x41\x2d". "\xb9\x1f\x45\x32\xcb\x39\x2f\x94\xae\x44\x6d\x69\xc1\xc9\x57". "\x8c\xe5\xf4\xa4\x3a\xb6\x70\x61\xf9\xbb\x41\xdc\x78\xf0\xf7". "\xbf\xa8\x8e\xe3\x77\x51\xce\x25\x2f\xdf\x27\x6b\x07\x30\x9f". "\xce\xdb\x59\x58\xaa\xb2\x2e\xdc\x90\x92\x82\x55\xfe\x25\x36". "\x49\x7f\x6d\x2d\x39\x51\xef\x3d\xc8\xa3\x87\x0b\xe7\xf2\xac". "\x90\xa0\x1d\xd8\xc7\xea\x93\x53\x3b\x21\x84\x2e\x52\x6c\xfb". "\x4f\x31\xda\xd1\xea\x45\x3e\xdc\xeb\x52\x81\x8c\x2b\xf4\x2a". "\xbc\x01\xc4\xe7\x68\x36\x9c\xd5\x2d\xc1\x61\xcb\x9a\x5f\x18". "\x00\x6a\xc8\x9a\x4e\xfd\x31\x5b\xce\x90\x4e\x45\xff\x7f\xea". "\xb2\x26\xad\xc1\x3a\x21\xa9\xe8\x7c\x14\xae\x81\x1e\xbe\xa3". "\x6d\xda\x92\x1b\xeb\xf2\x69\x76\x3e\xf1\x2b\xf7\x1a\x45\xd5". "\xb3\x81\xb1\xbe\x80\x7f\x24\xba\x0e\xd5\x68\x34\x3f\x1a\x29". "\x15\x0e\xc2\x26\x62\x0c\xaa\xa9\x20\x4c\x61\x65\x49\x07\xbe". "\x69\xf4\xc9\xec\x2f\x1c\xfa\x59\x2e\x72\xc0\x17\xc5\x4c\xfa". "\xba\x2f\x64\xab\xa9\xb4\xcb\xdc\xcb\x25\x5f\xcf\x0c\x87\xcc". "\xf0\x36\x2b\xce\x81\x5a\x22\x85\xa0\x50\x50\x97\x8e\xda\x36". "\x80\x74\xb5\x1e\x02\x3f\xd7\xc8\x29\x11\xeb\x1d\x3d\x74\x9f". "\x26\x1a\xa4\x3d\xf9\x0e\xf0\x2d\x5c\xa9\x43\xbf\x51\x6c\x8d". "\xe6\x78\xe0\x67\x57\xf0\xc8\x0e\x97\x9c\x57\x23\x30\xac\x63". "\xdf\x46\x98\xa4\xaf\x4e\xa7\xe5\xac\x31\xbd\xeb\x6a\xa0\xb0". "\xe4\x94\x7e\x51\xf6\x89\x81\x3e\xab\x4f\x64\xb7\xc5\x51\x71". "\xcd\x74\x02\xa9\x02\x99\x5c\xab\x0e\x14\x47\x3b\x04\xc1\x9b". "\x59\x1a\x93\x92\x4c\x71\x20\x5f\x6e\xd3\xf3\xa7\x47\x1b\x39". "\x3e\x73\x69\xe2\xec\xcb\x52\xb3\x5c\x7a\x95\x25\x3f\x16\x98". "\x60\xa8\xa2\x5d\xc4\x5a\x67\xe4\x11\x06\x06\xf9\x7a\xb4\x14". "\xe0\xbc\x7b\x13\x1d\x0f\xf2\xca\x0b\xd4\xaa\x71\x35\x3e\xd6". "\x2e\x2e\x5d\x7b\x15\xc9\x23\x1a\xa9\x24\x31\x48\xd4\xcf\x4a". "\xf4\x32\x17\x9b\x1d\x4b\xfe\x49\x69\xd6\xc0\x8f\xb9\xdb\x72". "\x52\x2c\xe8\xf3\xc4\xfc\x46\xf5\xb8\x1b\x05\x06\xcf\xcc\x23". "\x34\xbf\x25\x6a\xea\x3c\xc7\x64\xd4\xd5\xb3\x67\xed\x24\x27". "\xd3\x67\xc1\xbd\x9f\x7b\x7d\x19\x04\x5c\xd1\x96\x7e\xa5\xc7". "\xbb\xb2\x84\x68\x98\x38\x11\x90\xfb\x62\x15\xfd\xe6\xb7\x24". "\x77\xb2\x78\xc7\x73\x91\xc9\x60\x1d\x91\x6d\x04\x2b\x41\xe9". "\xc9\xfa\xe4\x98\x54\x83\x9a\x6e\x76\x8c\x21\xf9\x91\x38\x1f". "\xdc\xfe\x13\x09\x30\xd7\x53\x63\x62\xba\xe3\x2c\x70\xd5\xfc". "\x78\x35\x36\x79\x5d\xb6\x0e\x35\x3d\x46\x87\xfb\xf5\x64\x1f". "\x3e\xfd\x2f\x1c\xbb\xed\x95\x2d\xd6\x63\xdc\xa7\x6a\x39\x8f". "\xbd\xcb\x79\x95\xe9\x45\xbf\xe4\x3e\x05\x55\x00\xdb\x33\x28". "\x3a\x6c\xe2\x35\xbb\xac\x70\x52\x2b\xac\x4e\x11\x44\x58\x16". "\x21\xb4\xae\x0d\x6a\xb9\xdc\x85\x5d\x90\x11\x26\x85\xdb\xc3". "\xf0\x38\x6f\x8a\xff\x12\xf0\xc9\x9e\xf0\xfc\xae\x94\x11\x4d". "\xce\x96\x29\x09\x6c\xf4\x2a\x6c\xda\x1e\x4c\x4a\xa2\x96\x5a". "\xef\xc6\x38\x5c\x60\xa2\x28\x13\x58\x73\x96\xde\x59\x2a\x57". "\x64\x6c\x14\x94\x8a\x2e\x8e\x21\x3f\xa2\x43\xde\xf6\x2d\x23". "\x74\x5c\xbd\x7a\x10\xdb\x17\xa8\x93\xd0\x74\x86\x9d\x33\x07". "\x48\xee\xac\x18\x6d\x64\x61\x7b\x61\x2b\xa4\xa2\xab\x99\x59". "\xbe\x19\xd7\x19\x41\x1e\x61\x87\xad\x40\x5b\x69\x8c\x32\xf5". "\xb6\x49\xbe\x1f\xad\xd8\x0f\x3e\xd9\x62\xac\x3a\x76\xde\x32". "\xa3\xb2\x41\x95\xad\x17\x23\xab\xa1\x37\x9c\xab\x73\x79\x70". "\xd6\x66\x0d\x6e\x4d\x8b\xa0\xac\xe3\x44\x1e\x0a\xee\xf0\x74". "\x64\xd8\x44\xd1\x6c\xa6\xd5\x36\x2e\xd9\x55\x6e\x90\x63\xb7". "\xf7\x8e\xc6\x28\xa3\x40\x00\x60\x9a\x3c\xfe\xff\x03\x30\x11". "\x18\x92\x2f\x5b\x23\xe1\x4e\x99\xe4\x82\xc9\x51\xe2\x15\x6a". "\x76\x5c\x67\xae\xa3\xa2\x9c\x85\x51\xe0\x44\x89\x63\xa5\x71". "\x99\xbc\x2d\x9c\xab\x9a\xfb\x20\x37\x58\xd6\x2d\x8b\x7d\x42". "\x13\x35\x44\x4c\x11\x97\x66\x27\x17\xac\x44\xe8\x6a\x03\x78". "\xa2\x88\xc6\x36\x71\x5a\x5a\x5a\x72\xa3\xe9\x72\x0c\x91\x31". "\xfc\xae\x7b\xa0\x75\x21\x0a\xc1\x4b\x95\xcb\xe3\xc2\xee\x03". "\x0f\xb8\xb2\x51\xc7\xc8\x9c\x8d\x6d\x3a\xe7\x4e\x2c\xaa\xeb". "\x5e\x49\x93\xe0\x8f\xa1\x54\x93\xe7\x7c\x5d\x31\xc7\x05\x00". "\x28\x14\x57\x47\xb3\x05\x2d\x17\x92\x28\x45\xee\x85\x3a\x59". "\xb6\xa6\x04\xc0\x5c\x07\x1f\xe6\x5b\x36\x53\x62\x82\x64\xd5". "\xb6\xf2\xf5\x67\x19\x11\xee\xd2\x70\xc5\x14\x63\xc1\x75\xe1". "\x24\xe5\x01\x59\x52\x7c\x88\x17\xb4\xe0\x15\xe9\x12\x05\xcd". "\x88\x7a\xd5\xea\x45\xc3\xbb\x65\xd4\xdd\x0d\xde\x36\x94\x98". "\x0d\x2c\xfb\x3c\x2f\x69\xd0\x28\xe2\x85\xd9\x27\xf3\x7a\xad". "\x50\x68\x96\x54\x5e\xeb\xbc\x2a\x74\xde\xf3\x4e\x8b\x27\x0a". "\xcf\x4c\x60\x40\xe8\xc5\x72\xab\x8c\xfd\xe9\xab\xff\x51\xe5". "\xd6\xea\x9e\x34\x73\xe1\xe6\xf8\x5b\xb1\x10\xf0\xf9\x2d\x23". "\x0e\xfe\xe5\xf4\x8d\xb6\x6d\x37\x14\xed\x54\x97\x92\x5c\x68". "\x40\x88\xf1\x43\x29\xef\x5e\x96\x77\xa2\xe8\x3c\xae\x7f\xb1". "\x99\x17\xa7\x0c\x6f\xe2\x43\x32\x9b\x14\x43\xf2\x15\x6b\x13". "\x10\x68\x56\x0b\xaa\x06\x2e\xc0\xf8\xde\x9e\x54\x9d\xba\xff". "\x76\x26\x6d\x5e\x9e\x88\x3a\x2b\x9b\x20\x43\xb9\x1a\x0e\x58". "\x65\xec\xdb\x9e\x97\xb8\xfb\x03\x6c\xb0\x7f\xa2\xf1\xf4\x27". "\x24\x21\x47\x51\x21\x40\x45\x28\x71\xf7\xa1\x6b\xbe\x0e\xc8". "\x3f\x9b\xda\x62\x9d\x73\xf7\x5f\x70\x6c\xba\x1e\xeb\x16\x5c". "\x2e\x44\x0a\x22\x02\x6c\xbe\xb9\x69\x93\xfd\xa5\x33\x26\x64". "\x24\x6c\xc2\x3d\x2f\xf3\xd1\x97\xde\x60\x43\x1c\x0d\x1b\x94". "\xb3\x48\x45\x7c\xd5\xd0\x71\x4d\xad\xbf\xa4\x0a\x22\x27\x04". "\x38\x84\x19\x66\x63\xf0\xf3\xfc\xb0\xf3\x1d\xea\xba\xb9\xe4". "\xe5\x80\xed\xe3\xf1\x78\x24\xc3\x25\x27\x71\x81\xc2\xec\x54". "\xed\xcc\x63\xf7\x39\xcd\x83\xdf\x32\x88\xc0\x3b\xd4\x62\xb8". "\xea\x34\xd8\xcf\xbc\x3a\x89\x38\x64\x60\x44\xde\xb6\x76\x59". "\xb1\x95\x6a\x26\x08\xf0\xf4\x71\x25\x8b\xf8\x81\xdd\x0d\x2f". "\x8c\xe2\x70\xc2\x96\xc2\xd8\x9b\xe4\x3f\xec\x8b\xfd\xbd\xc9". "\x36\x33\xb7\xbc\x59\x37\x19\x09\x30\x5e\xef\x67\xae\x67\x48". "\x72\x0b\xf4\x2a\x82\xff\xcb\xd7\xd9\x9d\x6d\x7c\xa6\x20\x42". "\x50\x2b\x0a\x2f\x45\x99\x5b\x76\x6d\x99\x39\xa9\xb6\x32\x06". "\x11\xf8\x19\xd1\x3f\xc0\xd6\x1f\x67\xfa\xd5\xae\x7a\x71\x8c". "\xbc\x3d\xb4\x5f\x5c\x81\x7c\xa1\x39\x70\x0a\x17\x24\xb7\x22". "\x86\x50\xd8\x1f\xc8\x6c\x59\x9a\xdc\xf0\x71\x01\xda\xd8\x53". "\x98\x1c\x73\x36\xf1\x09\x86\xc9\xa7\x26\x25\xc0\x03\x3e\x13". "\x4e\x29\xeb\xf0\x8d\xe3\x38\x03\x54\xee\x37\xfb\x51\x2e\xb4". "\xf6\x12\x1f\xb2\x8c\x66\x75\x00\x30\x5b\xef\x59\xf9\x63\xa9". "\x74\x07\x91\xe4\x9c\xb7\xc9\x89\xd9\xa9\x51\x93\xcb\xb1\xa7". "\x64\x08\x79\x8f\xb4\x6d\x09\xd7\xc5\xbf\x0a\xdb\x50\xe0\x1c". "\x83\xca\xf8\xcf\xa7\x81\xbb\x0b\xe6\xcf\x1b\x0e\x0a\xe0\xcd". "\x68\xe2\xde\xc4\x2d\xba\x55\xc7\xc7\x1e\x6c\x5e\xca\x9b\x20". "\x75\x96\x94\x92\x84\xec\xf5\x22\x25\x78\x67\xcd\xbe\x01\xfe". "\x53\xa5\xcc\x6a\x40\x33\x83\xa4\x7a\x44\x93\x0b\xf9\x4c\xb2". "\x95\xb6\x7e\x4b\xa4\xc8\x86\xfe\x8a\xf1\x77\x40\x56\x13\xc1". "\x31\x2c\x8c\x4a\xa8\x89\x61\x0c\x39\x33\x78\x8c\xd5\x50\x3b". "\x89\xc3\xd3\x80\x1c\xa7\xb6\x36\xc2\x00\x8d\x0a\x7f\xcc\xd3". "\x20\x74\x60\x70\x36\x7d\xda\xdc\xc4\x49\x04\xf0\xe6\x6c\xd1". "\xbe\xcb\xfb\xf1\xa2\xd6\xd4\xe4\x97\x3f\x35\x09\x5b\xda\x06". "\x6b\x6d\x86\x53\x23\x0c\x26\x51\x2a\x15\xaa\xe2\x73\xfb\xc7". "\x41\x54\xdc\x5d\x99\x0b\x0a\x1e\xd4\xdb\x70\xa3\x8e\xfd\x5b". "\xf0\xa8\x3e\x9b\xff\x57\x98\xbc\xd9\x2a\x56\xd3\x19\xf9\x0b". "\xd9\x67\x0f\x10\x9c\x23\xe5\x6b\x12\xc6\xb6\x4b\xd1\x0c\xe9". "\x45\x36\xdf\x54\x6f\xcc\xfe\xb5\xcc\xb9\xfe\xde\xc8\xb5\xc9". "\x04\x59\x61\x75\x1e\x72\x37\x54\xfd\xc6\xc3\x7e\x74\xae\x55". "\x31\x6a\xbc\x8a\xd8\x45\x91\xe2\x8d\x20\x97\x71\xe7\x55\xd6". "\x8a\xb8\x82\x2a\x27\x4f\xdc\x53\x89\x28\xf7\x3a\xfe\x07\xef". "\x60\xb2\x32\x7c\xbc\x13\xc4\x3d\xda\xd7\xfb\xb8\x61\x7d\x69". "\xae\x0e\x9a\x71\xd6\x00\x26\x97\xff\xdb\xe6\xbe\x45\x7a\xb5". "\x00\x31\xfd\x70\xcc\xd7\x34\x88\xe4\x05\x61\xf5\x72\x1d\x14". "\xf0\x7e\x90\xdb\x0e\xc7\xda\xd4\xf3\x99\xd4\x60\xd9\xa7\xc8". "\x5b\x33\x34\xb5\x23\x74\x2c\x5f\x6b\x56\x95\x9c\x1b\x2a\xac". "\xf9\xfe\x46\xc3\xf1\x9b\x24\x7e\x4b\xca\x25\x58\x41\x10\x63". "\xe8\xe7\x68\xda\xcc\xb6\x4d\x5b\x8f\xc9\xa9\x31\xeb\x5c\x2a". "\xcf\x9d\x89\xd5\x51\x93\x80\x30\xf4\xc9\x2c\x8c\xb8\x8c\x62". "\xd6\x33\xbd\x95\x9f\xfa\x19\xf2\x48\x28\x09\x73\xc9\x53\x61". "\x94\x3a\x62\x68\x6c\xc6\xd6\x0a\xb4\xae\x27\x96\xfb\x29\xd7". "\x46\x67\x11\x7a\xe8\x3a\x9a\x3f\xf4\x9a\x75\xed\x24\x67\x45". "\x79\xdc\x8b\x19\xf2\xef\x57\xaa\xc7\x84\xff\x9d\x2d\xc3\xa8". "\x85\x54\xb7\x9d\xe1\xd6\x2b\xe9\x31\x9d\x6c\xb8\x4e\x76\x50". "\x80\x44\x46\x8f\x5e\x7e\x20\xaa\xa0\x8a\x36\x6b\xef\xd1\x75". "\xf8\x3f\x20\xdd\x09\x73\xbf\xa5\xf7\xb4\x87\xb2\x44\xc0\x0f". "\x10\xc0\x95\x2e\x8a\x42\xfa\xc3\x49\x17\xb9\xb5\x1a\xc3\x80". "\x93\x0c\xd8\xe3\xcd\xa4\x38\x61\x7a\x22\x73\x8e\x32\x8f\x55". "\x9c\x91\x08\xd9\x65\xa9\x02\x28\xc6\x59\xc8\x51\x32\x20\x48". "\xea\x2c\xae\x0e\xa6\x35\x5b\xe2\x63\xf9\xf2\x9d\x5f\xe3\x45". "\xdc\x41\xba\xfb\x40\xcc\x8d\xde\x6c\x3d\x50\x97\x9d\x83\xa0". "\xda\x41\x61\xba\xaf\xf8\x74\xd2\x21\x7b\x09\xcc\x83\xe1\x08". "\x01\x04\x42\xce\xcb\xec\x1d\x6b\xb7\x6f\x0f\x4b\xd4\x53\x90". "\x55\x3b\xcf\x9f\x93\xb8\xad\xce\x5f\x13\x83\xb3\x89\x6f\x5a". "\x1b\xa4\xf5\x95\x4b\xb4\x22\x22\x1d\x35\xaa\xfa\xc7\x14\x8c". "\xcd\x50\x66\x14\x47\xff\x67\xb2\xf8\x12\x09\xb3\x8a\xe5\x7d". "\xb8\xc9\xe4\x89\xf7\xa4\xb5\x70\xfa\x2d\xeb\x95\x89\xec\xbb". "\x49\x59\xd2\xc1\x6d\x0e\x06\xe4\x5e\xd5\x13\x13\x0d\x72\x6e". "\xf0\x6d\xa9\xd5\xe7\x54\x68\x35\xcd\xd0\xd5\xa6\xe5\xb2\xe4". "\xb1\x19\xe4\xf1\xe3\x8a\x56\x4c\x3b\x3d\xb8\x03\xfe\x22\x2f". "\xc6\xdc\x88\x7b\xca\x5c\xc6\xdd\x17\x34\x08\x22\xf0\x17\x61". "\x0e\x60\x9c\xb4\x27\x57\x30\x6e\xb8\x4f\xdd\x25\x7b\xef\x9e". "\x8e\x88\x6b\xd8\x10\x23\xc2\x44\x53\x73\x64\x8f\x40\x22\xe1". "\xe8\xa2\xb0\x3f\x8a\x07\x66\xcd\x64\x4f\x9c\x1e\x89\x76\x04". "\x6d\xab\xc2\xbb\x16\x85\x80\x01\xa5\xb1\xe2\x12\x04\x2e\x39". "\x87\x8c\xee\xbc\xfb\x07\x6d\x03\x4c\x3a\xa5\x7b\x95\xd9\xd7". "\xd6\xee\x2b\xe9\xcb\xe6\xec\xa8\x84\x6a\x42\xf9\xb2\x25\xc8". "\xf3\x6a\xaa\x34\x3b\xd9\x72\xd9\x70\x81\x3b\xd4\x5e\x66\x97". "\x1b\xe6\x2b\x88\x71\x82\xa3\x8a\x98\xb0\x16\xd9\xbb\x97\x8b". "\x57\x79\x41\x56\x6e\xc2\x8f\xdf\xfa\x5b\xc7\x68\x5b\xb8\x09". "\x41\x31\x7c\x19\xe1\x95\x2e\x05\x4c\xac\x38\x81\xda\xb3\x8b". "\x3e\x1c\x79\x9a\x31\xac\x3e\x3d\x6d\xab\xf3\x5a\x5e\xc7\x6e". "\x8e\x39\xcd\x7b\x6f\x62\xee\xb9\x73\xdd\x82\x42\x6f\x09\xe4". "\xc3\xae\x92\xe8\x18\x99\xa0\x5e\xa2\x12\xf4\xe2\xe0\xe6\x95". "\x58\x3a\x45\xad\xfe\x23\x79\x5f\x82\xce\x95\x88\x73\xeb\x46". "\xc8\x00\xac\xc3\x2a\xdc\x7e\xab\x9b\xf8\xbb\x46\x5c\xa8\x46". "\xbc\xfd\x99\xae\x4c\xa7\x77\xeb\x7c\x58\xbf\xbb\x52\x68\x62". "\x3d\x0b\x79\x64\x38\x65\xa7\xcb\x7b\xe9\xb2\x33\xb5\x59\x52". "\x7b\x17\xb4\x02\x2b\x07\x0d\x3a\x11\x57\x92\xa5\x22\x2b\xbc". "\xe6\x97\x05\x12\x05\xe7\x91\xe3\xfa\xae\x15\xbe\x20\xe5\x5c". "\x71\x24\x80\x85\xc9\x66\xc1\x53\x5c\x8f\x08\xd4\x52\xe1\x10". "\xb6\xd6\x20\x08\x01\x79\x33\x9f\x1b\xbd\xa0\xab\x7c\xb1\xd9". "\xdc\xca\x44\x22\x49\xb7\xb7\x3d\x84\xac\x92\xf4\xfa\x0a\xc9". "\xc5\xb2\x42\x2b\x9a\x63\xbb\x8a\x82\x04\x2f\xf7\xe9\x30\x05". "\x67\x32\xd1\x41\x1a\x69\x6e\xb9\xf8\x5f\x6d\xb7\xe5\x4e\x85". "\x21\xfa\x16\x8a\x44\xfd\xf6\xd9\xa2\x5f\x68\x2b\xf3\xe2\x3c". "\x8a\x69\xd2\xc1\x38\xed\x83\xef\x0d\x53\x86\x93\x32\x23\xc6". "\x14\x0c\xb0\xb6\x6e\x77\xa4\x20\x0f\xb1\x6e\xe2\xce\xca\x6f". "\x93\x1c\x3a\x8f\xd0\xd2\x5a\x6e\x30\xd6\x8e\x5f\x4b\xa5\xef". "\xa9\x62\xeb\x28\xa0\x5e\x3f\xc1\xbc\x0a\x68\xab\xd7\xfa\xa2". "\xb7\x8f\x12\xb0\x99\xbc\x93\x20\xb8\x95\x8d\xca\xc7\xa7\xd9". "\x2e\x19\xac\x06\xb9\x4e\x56\x8e\x74\xef\x2a\x04\xd8\x75\x04". "\x38\x2a\xc7\xa0\xa4\x89\xf3\xa4\x8a\xd4\x2c\x2c\x58\x6f\x00". "\x03\x23\xb8\xaf\x02\x48\x7d\x50\x46\x6f\x5a\x08\x41\xe3\x56". "\x6d\xcb\xe2\x4f\xea\x8e\xab\x74\xcd\xf9\xef\xcf\xf9\x1e\xf1". "\xf8\xb9\x6c\xaa\x3b\x37\xd1\x21\x42\x67\xec\xd6\x44\x55\x33". "\xe8\x1d\xa4\x18\xf3\x73\x82\xb4\x50\x59\xc2\x34\x36\x05\xeb"; $expect = Filesystem::readFile(dirname(__FILE__).'/base85/expect2.txt'); $expect = trim($expect); $this->assertEqual( $expect, ArcanistBundle::encodeBase85($data)); } } diff --git a/src/parser/__tests__/bundle.git.tgz b/src/parser/__tests__/bundle.git.tgz index 41e85594..99178e93 100644 Binary files a/src/parser/__tests__/bundle.git.tgz and b/src/parser/__tests__/bundle.git.tgz differ diff --git a/src/parser/diff/ArcanistDiffChange.php b/src/parser/diff/ArcanistDiffChange.php index 88da67b2..d811249b 100644 --- a/src/parser/diff/ArcanistDiffChange.php +++ b/src/parser/diff/ArcanistDiffChange.php @@ -1,253 +1,274 @@ originalFileData = $original_file_data; + return $this; + } + + public function getOriginalFileData() { + return $this->originalFileData; + } + + public function setCurrentFileData($current_file_data) { + $this->currentFileData = $current_file_data; + return $this; + } + + public function getCurrentFileData() { + return $this->currentFileData; + } + public function toDictionary() { $hunks = array(); foreach ($this->hunks as $hunk) { $hunks[] = $hunk->toDictionary(); } return array( 'metadata' => $this->metadata, 'oldPath' => $this->oldPath, 'currentPath' => $this->currentPath, 'awayPaths' => $this->awayPaths, 'oldProperties' => $this->oldProperties, 'newProperties' => $this->newProperties, 'type' => $this->type, 'fileType' => $this->fileType, 'commitHash' => $this->commitHash, 'hunks' => $hunks, ); } public static function newFromDictionary(array $dict) { $hunks = array(); foreach ($dict['hunks'] as $hunk) { $hunks[] = ArcanistDiffHunk::newFromDictionary($hunk); } $obj = new ArcanistDiffChange(); $obj->metadata = $dict['metadata']; $obj->oldPath = $dict['oldPath']; $obj->currentPath = $dict['currentPath']; // TODO: The backend is shipping down some bogus data, e.g. diff 199453. // Should probably clean this up. $obj->awayPaths = nonempty($dict['awayPaths'], array()); $obj->oldProperties = nonempty($dict['oldProperties'], array()); $obj->newProperties = nonempty($dict['newProperties'], array()); $obj->type = $dict['type']; $obj->fileType = $dict['fileType']; $obj->commitHash = $dict['commitHash']; $obj->hunks = $hunks; return $obj; } public function getChangedLines($type) { $lines = array(); foreach ($this->hunks as $hunk) { $lines += $hunk->getChangedLines($type); } return $lines; } public function getAllMetadata() { return $this->metadata; } public function setMetadata($key, $value) { $this->metadata[$key] = $value; return $this; } public function getMetadata($key) { return idx($this->metadata, $key); } public function setCommitHash($hash) { $this->commitHash = $hash; return $this; } public function getCommitHash() { return $this->commitHash; } public function addAwayPath($path) { $this->awayPaths[] = $path; return $this; } public function getAwayPaths() { return $this->awayPaths; } public function setFileType($type) { $this->fileType = $type; return $this; } public function getFileType() { return $this->fileType; } public function setType($type) { $this->type = $type; return $this; } public function getType() { return $this->type; } public function setOldProperty($key, $value) { $this->oldProperties[$key] = $value; return $this; } public function setNewProperty($key, $value) { $this->newProperties[$key] = $value; return $this; } public function getOldProperties() { return $this->oldProperties; } public function getNewProperties() { return $this->newProperties; } public function setCurrentPath($path) { $this->currentPath = $this->filterPath($path); return $this; } public function getCurrentPath() { return $this->currentPath; } public function setOldPath($path) { $this->oldPath = $this->filterPath($path); return $this; } public function getOldPath() { return $this->oldPath; } public function addHunk(ArcanistDiffHunk $hunk) { $this->hunks[] = $hunk; return $this; } public function getHunks() { return $this->hunks; } public function convertToBinaryChange() { $this->hunks = array(); $this->setFileType(ArcanistDiffChangeType::FILE_BINARY); return $this; } protected function filterPath($path) { if ($path == '/dev/null') { return null; } return $path; } public function renderTextSummary() { $type = $this->getType(); $file = $this->getFileType(); $char = ArcanistDiffChangeType::getSummaryCharacterForChangeType($type); $attr = ArcanistDiffChangeType::getShortNameForFileType($file); if ($attr) { $attr = '('.$attr.')'; } $summary = array(); $summary[] = sprintf( "%s %5.5s %s", $char, $attr, $this->getCurrentPath()); if (ArcanistDiffChangeType::isOldLocationChangeType($type)) { foreach ($this->getAwayPaths() as $path) { $summary[] = ' to: '.$path; } } if (ArcanistDiffChangeType::isNewLocationChangeType($type)) { $summary[] = ' from: '.$this->getOldPath(); } return implode("\n", $summary); } public function getSymlinkTarget() { if ($this->getFileType() != ArcanistDiffChangeType::FILE_SYMLINK) { throw new Exception("Not a symlink!"); } $hunks = $this->getHunks(); $hunk = reset($hunks); $corpus = $hunk->getCorpus(); $match = null; if (!preg_match('/^\+(?:link )?(.*)$/m', $corpus, $match)) { throw new Exception("Failed to extract link target!"); } return trim($match[1]); } public function setNeedsSyntheticGitHunks($needs_synthetic_git_hunks) { $this->needsSyntheticGitHunks = $needs_synthetic_git_hunks; return $this; } public function getNeedsSyntheticGitHunks() { return $this->needsSyntheticGitHunks; } } diff --git a/src/repository/api/ArcanistGitAPI.php b/src/repository/api/ArcanistGitAPI.php index f640da07..38865f8e 100644 --- a/src/repository/api/ArcanistGitAPI.php +++ b/src/repository/api/ArcanistGitAPI.php @@ -1,967 +1,972 @@ setCWD($this->getPath()); return $future; } public function getSourceControlSystemName() { return 'git'; } public function getMetadataPath() { return $this->getPath('.git'); } public function getHasCommits() { return !$this->repositoryHasNoCommits; } public function setRelativeCommit($relative_commit) { $this->relativeCommit = $relative_commit; return $this; } public function getLocalCommitInformation() { if ($this->repositoryHasNoCommits) { // Zero commits. throw new Exception( "You can't get local commit information for a repository with no ". "commits."); } else if ($this->relativeCommit == self::GIT_MAGIC_ROOT_COMMIT) { // One commit. $against = 'HEAD'; } else { // 2..N commits. We include commits reachable from HEAD which are // not reachable from the relative commit; this is consistent with // user expectations even though it is not actually the diff range. // Particularly: // // | // D <----- master branch // | // C Y <- feature branch // | /| // B X // | / // A // | // // If "A, B, C, D" are master, and the user is at Y, when they run // "arc diff B" they want (and get) a diff of B vs Y, but they think about // this as being the commits X and Y. If we log "B..Y", we only show // Y. With "Y --not B", we show X and Y. $against = csprintf('%s --not %s', 'HEAD', $this->getRelativeCommit()); } // NOTE: Windows escaping of "%" symbols apparently is inherently broken; // when passed throuhgh escapeshellarg() they are replaced with spaces. // TODO: Learn how cmd.exe works and find some clever workaround? // NOTE: If we use "%x00", output is truncated in Windows. list($info) = $this->execxLocal( phutil_is_windows() ? 'log %C --format=%C --' : 'log %C --format=%s --', $against, // NOTE: "%B" is somewhat new, use "%s%n%n%b" instead. '%H%x01%T%x01%P%x01%at%x01%an%x01%s%x01%s%n%n%b%x02'); $commits = array(); $info = trim($info, " \n\2"); if (!strlen($info)) { return array(); } $info = explode("\2", $info); foreach ($info as $line) { list($commit, $tree, $parents, $time, $author, $title, $message) = explode("\1", trim($line), 7); $message = rtrim($message); $commits[$commit] = array( 'commit' => $commit, 'tree' => $tree, 'parents' => array_filter(explode(' ', $parents)), 'time' => $time, 'author' => $author, 'summary' => $title, 'message' => $message, ); } return $commits; } public function getRelativeCommit() { if ($this->relativeCommit === null) { // Detect zero-commit or one-commit repositories. There is only one // relative-commit value that makes any sense in these repositories: the // empty tree. list($err) = $this->execManualLocal('rev-parse --verify HEAD^'); if ($err) { list($err) = $this->execManualLocal('rev-parse --verify HEAD'); if ($err) { $this->repositoryHasNoCommits = true; } $this->relativeCommit = self::GIT_MAGIC_ROOT_COMMIT; if ($this->repositoryHasNoCommits) { $this->setBaseCommitExplanation( "the repository has no commits."); } else { $this->setBaseCommitExplanation( "the repository has only one commit."); } return $this->relativeCommit; } if ($this->getBaseCommitArgumentRules() || $this->getWorkingCopyIdentity()->getConfigFromAnySource('base')) { $base = $this->resolveBaseCommit(); if (!$base) { throw new ArcanistUsageException( "None of the rules in your 'base' configuration matched a valid ". "commit. Adjust rules or specify which commit you want to use ". "explicitly."); } $this->relativeCommit = $base; return $this->relativeCommit; } $do_write = false; $default_relative = null; $working_copy = $this->getWorkingCopyIdentity(); if ($working_copy) { $default_relative = $working_copy->getConfig( 'git.default-relative-commit'); $this->setBaseCommitExplanation( "it is the merge-base of '{$default_relative}' and HEAD, as ". "specified in 'git.default-relative-commit' in '.arcconfig'. This ". "setting overrides other settings."); } if (!$default_relative) { list($err, $upstream) = $this->execManualLocal( "rev-parse --abbrev-ref --symbolic-full-name '@{upstream}'"); if (!$err) { $default_relative = trim($upstream); $this->setBaseCommitExplanation( "it is the merge-base of '{$default_relative}' (the Git upstream ". "of the current branch) HEAD."); } } if (!$default_relative) { $default_relative = $this->readScratchFile('default-relative-commit'); $default_relative = trim($default_relative); if ($default_relative) { $this->setBaseCommitExplanation( "it is the merge-base of '{$default_relative}' and HEAD, as ". "specified in '.git/arc/default-relative-commit'."); } } if (!$default_relative) { // TODO: Remove the history lesson soon. echo phutil_console_format( "** Select a Default Commit Range **\n\n"); echo phutil_console_wrap( "You're running a command which operates on a range of revisions ". "(usually, from some revision to HEAD) but have not specified the ". "revision that should determine the start of the range.\n\n". "Previously, arc assumed you meant 'HEAD^' when you did not specify ". "a start revision, but this behavior does not make much sense in ". "most workflows outside of Facebook's historic git-svn workflow.\n\n". "arc no longer assumes 'HEAD^'. You must specify a relative commit ". "explicitly when you invoke a command (e.g., `arc diff HEAD^`, not ". "just `arc diff`) or select a default for this working copy.\n\n". "In most cases, the best default is 'origin/master'. You can also ". "select 'HEAD^' to preserve the old behavior, or some other remote ". "or branch. But you almost certainly want to select ". "'origin/master'.\n\n". "(Technically: the merge-base of the selected revision and HEAD is ". "used to determine the start of the commit range.)"); $prompt = "What default do you want to use? [origin/master]"; $default = phutil_console_prompt($prompt); if (!strlen(trim($default))) { $default = 'origin/master'; } $default_relative = $default; $do_write = true; } list($object_type) = $this->execxLocal( 'cat-file -t %s', $default_relative); if (trim($object_type) !== 'commit') { throw new Exception( "Relative commit '{$default_relative}' is not the name of a commit!"); } if ($do_write) { // Don't perform this write until we've verified that the object is a // valid commit name. $this->writeScratchFile('default-relative-commit', $default_relative); $this->setBaseCommitExplanation( "it is the merge-base of '{$default_relative}' and HEAD, as you ". "just specified."); } list($merge_base) = $this->execxLocal( 'merge-base %s HEAD', $default_relative); $this->relativeCommit = trim($merge_base); } return $this->relativeCommit; } private function getDiffFullOptions($detect_moves_and_renames = true) { $options = array( self::getDiffBaseOptions(), '--no-color', '--src-prefix=a/', '--dst-prefix=b/', '-U'.$this->getDiffLinesOfContext(), ); if ($detect_moves_and_renames) { $options[] = '-M'; $options[] = '-C'; } return implode(' ', $options); } private function getDiffBaseOptions() { $options = array( // Disable external diff drivers, like graphical differs, since Arcanist // needs to capture the diff text. '--no-ext-diff', // Disable textconv so we treat binary files as binary, even if they have // an alternative textual representation. TODO: Ideally, Differential // would ship up the binaries for 'arc patch' but display the textconv // output in the visual diff. '--no-textconv', ); return implode(' ', $options); } public function getFullGitDiff() { $options = $this->getDiffFullOptions(); list($stdout) = $this->execxLocal( "diff {$options} %s --", $this->getRelativeCommit()); return $stdout; } /** * @param string Path to generate a diff for. * @param bool If true, detect moves and renames. Otherwise, ignore * moves/renames; this is useful because it prompts git to * generate real diff text. */ public function getRawDiffText($path, $detect_moves_and_renames = true) { $options = $this->getDiffFullOptions($detect_moves_and_renames); list($stdout) = $this->execxLocal( "diff {$options} %s -- %s", $this->getRelativeCommit(), $path); return $stdout; } public function getBranchName() { // TODO: consider: // // $ git rev-parse --abbrev-ref `git symbolic-ref HEAD` // // But that may fail if you're not on a branch. list($stdout) = $this->execxLocal('branch --no-color'); $matches = null; if (preg_match('/^\* (.+)$/m', $stdout, $matches)) { return $matches[1]; } return null; } public function getSourceControlPath() { // TODO: Try to get something useful here. return null; } public function getGitCommitLog() { $relative = $this->getRelativeCommit(); if ($this->repositoryHasNoCommits) { // No commits yet. return ''; } else if ($relative == self::GIT_MAGIC_ROOT_COMMIT) { // First commit. list($stdout) = $this->execxLocal( 'log --format=medium HEAD'); } else { // 2..N commits. list($stdout) = $this->execxLocal( 'log --first-parent --format=medium %s..HEAD', $this->getRelativeCommit()); } return $stdout; } public function getGitHistoryLog() { list($stdout) = $this->execxLocal( 'log --format=medium -n%d %s', self::SEARCH_LENGTH_FOR_PARENT_REVISIONS, $this->getRelativeCommit()); return $stdout; } public function getSourceControlBaseRevision() { list($stdout) = $this->execxLocal( 'rev-parse %s', $this->getRelativeCommit()); return rtrim($stdout, "\n"); } public function getCanonicalRevisionName($string) { $match = null; if (preg_match('/@([0-9]+)$/', $string, $match)) { list($stdout) = $this->execxLocal( 'svn find-rev r%d', $match[1]); } else { list($stdout) = $this->execxLocal( 'show -s --format=%C %s', '%H', $string); } return rtrim($stdout); } public function getWorkingCopyStatus() { if (!isset($this->status)) { $options = $this->getDiffBaseOptions(); // -- parallelize these slow cpu bound git calls. // Find committed changes. $committed_future = $this->buildLocalFuture( array( "diff {$options} --raw %s --", $this->getRelativeCommit(), )); // Find uncommitted changes. $uncommitted_future = $this->buildLocalFuture( array( "diff {$options} --raw %s --", $this->repositoryHasNoCommits ? self::GIT_MAGIC_ROOT_COMMIT : 'HEAD', )); // Untracked files $untracked_future = $this->buildLocalFuture( array( 'ls-files --others --exclude-standard', )); // TODO: This doesn't list unstaged adds. It's not clear how to get that // list other than "git status --porcelain" and then parsing it. :/ // Unstaged changes $unstaged_future = $this->buildLocalFuture( array( 'ls-files -m', )); $futures = array( $committed_future, $uncommitted_future, $untracked_future, $unstaged_future ); Futures($futures)->resolveAll(); // -- read back and process the results list($stdout, $stderr) = $committed_future->resolvex(); $files = $this->parseGitStatus($stdout); list($stdout, $stderr) = $uncommitted_future->resolvex(); $uncommitted_files = $this->parseGitStatus($stdout); foreach ($uncommitted_files as $path => $mask) { $mask |= self::FLAG_UNCOMMITTED; if (!isset($files[$path])) { $files[$path] = 0; } $files[$path] |= $mask; } list($stdout, $stderr) = $untracked_future->resolvex(); $stdout = rtrim($stdout, "\n"); if (strlen($stdout)) { $stdout = explode("\n", $stdout); foreach ($stdout as $file) { $files[$file] = self::FLAG_UNTRACKED; } } list($stdout, $stderr) = $unstaged_future->resolvex(); $stdout = rtrim($stdout, "\n"); if (strlen($stdout)) { $stdout = explode("\n", $stdout); foreach ($stdout as $file) { $files[$file] = isset($files[$file]) ? ($files[$file] | self::FLAG_UNSTAGED) : self::FLAG_UNSTAGED; } } $this->status = $files; } return $this->status; } public function amendCommit($message) { $tmp_file = new TempFile(); Filesystem::writeFile($tmp_file, $message); $this->execxLocal( 'commit --amend --allow-empty -F %s', $tmp_file); } public function getPreReceiveHookStatus($old_ref, $new_ref) { $options = $this->getDiffBaseOptions(); list($stdout) = $this->execxLocal( "diff {$options} --raw %s %s --", $old_ref, $new_ref); return $this->parseGitStatus($stdout, $full = true); } private function parseGitStatus($status, $full = false) { static $flags = array( 'A' => self::FLAG_ADDED, 'M' => self::FLAG_MODIFIED, 'D' => self::FLAG_DELETED, ); $status = trim($status); $lines = array(); foreach (explode("\n", $status) as $line) { if ($line) { $lines[] = preg_split("/[ \t]/", $line); } } $files = array(); foreach ($lines as $line) { $mask = 0; $flag = $line[4]; $file = $line[5]; foreach ($flags as $key => $bits) { if ($flag == $key) { $mask |= $bits; } } if ($full) { $files[$file] = array( 'mask' => $mask, 'ref' => rtrim($line[3], '.'), ); } else { $files[$file] = $mask; } } return $files; } public function getBlame($path) { // TODO: 'git blame' supports --porcelain and we should probably use it. list($stdout) = $this->execxLocal( 'blame --date=iso -w -M %s -- %s', $this->getRelativeCommit(), $path); $blame = array(); foreach (explode("\n", trim($stdout)) as $line) { if (!strlen($line)) { continue; } // lines predating a git repo's history are blamed to the oldest revision, // with the commit hash prepended by a ^. we shouldn't count these lines // as blaming to the oldest diff's unfortunate author if ($line[0] == '^') { continue; } $matches = null; $ok = preg_match( '/^([0-9a-f]+)[^(]+?[(](.*?) +\d\d\d\d-\d\d-\d\d/', $line, $matches); if (!$ok) { throw new Exception("Bad blame? `{$line}'"); } $revision = $matches[1]; $author = $matches[2]; $blame[] = array($author, $revision); } return $blame; } public function getOriginalFileData($path) { return $this->getFileDataAtRevision($path, $this->getRelativeCommit()); } public function getCurrentFileData($path) { return $this->getFileDataAtRevision($path, 'HEAD'); } private function parseGitTree($stdout) { $result = array(); $stdout = trim($stdout); if (!strlen($stdout)) { return $result; } $lines = explode("\n", $stdout); foreach ($lines as $line) { $matches = array(); $ok = preg_match( '/^(\d{6}) (blob|tree) ([a-z0-9]{40})[\t](.*)$/', $line, $matches); if (!$ok) { throw new Exception("Failed to parse git ls-tree output!"); } $result[$matches[4]] = array( 'mode' => $matches[1], 'type' => $matches[2], 'ref' => $matches[3], ); } return $result; } private function getFileDataAtRevision($path, $revision) { // NOTE: We don't want to just "git show {$revision}:{$path}" since if the // path was a directory at the given revision we'll get a list of its files // and treat it as though it as a file containing a list of other files, // which is silly. list($stdout) = $this->execxLocal( 'ls-tree %s -- %s', $revision, $path); $info = $this->parseGitTree($stdout); if (empty($info[$path])) { // No such path, or the path is a directory and we executed 'ls-tree dir/' // and got a list of its contents back. return null; } if ($info[$path]['type'] != 'blob') { // Path is or was a directory, not a file. return null; } list($stdout) = $this->execxLocal( 'cat-file blob %s', $info[$path]['ref']); return $stdout; } /** * Returns names of all the branches in the current repository. * * @return list> Dictionary of branch information. */ public function getAllBranches() { list($branch_info) = $this->execxLocal( 'branch --no-color'); $lines = explode("\n", rtrim($branch_info)); $result = array(); foreach ($lines as $line) { if (preg_match('/^[* ]+\(no branch\)/', $line)) { // This is indicating that the working copy is in a detached state; // just ignore it. continue; } list($current, $name) = preg_split('/\s+/', $line, 2); $result[] = array( 'current' => !empty($current), 'name' => $name, ); } return $result; } public function getWorkingCopyRevision() { list($stdout) = $this->execxLocal('rev-parse HEAD'); return rtrim($stdout, "\n"); } public function isHistoryDefaultImmutable() { return false; } public function supportsAmend() { return true; } public function supportsRelativeLocalCommits() { return true; } + public function setDefaultBaseCommit() { + $this->setRelativeCommit('HEAD^'); + return $this; + } + public function hasLocalCommit($commit) { try { if (!$this->getCanonicalRevisionName($commit)) { return false; } } catch (CommandException $exception) { return false; } return true; } public function parseRelativeLocalCommit(array $argv) { if (count($argv) == 0) { return; } if (count($argv) != 1) { throw new ArcanistUsageException("Specify only one commit."); } $base = reset($argv); if ($base == ArcanistGitAPI::GIT_MAGIC_ROOT_COMMIT) { $merge_base = $base; $this->setBaseCommitExplanation( "you explicitly specified the empty tree."); } else { list($err, $merge_base) = $this->execManualLocal( 'merge-base %s HEAD', $base); if ($err) { throw new ArcanistUsageException( "Unable to find any git commit named '{$base}' in this repository."); } $this->setBaseCommitExplanation( "it is the merge-base of '{$base}' and HEAD, as you explicitly ". "specified."); } $this->setRelativeCommit(trim($merge_base)); } public function getAllLocalChanges() { $diff = $this->getFullGitDiff(); if (!strlen(trim($diff))) { return array(); } $parser = new ArcanistDiffParser(); return $parser->parseDiff($diff); } public function supportsLocalBranchMerge() { return true; } public function performLocalBranchMerge($branch, $message) { if (!$branch) { throw new ArcanistUsageException( "Under git, you must specify the branch you want to merge."); } $err = phutil_passthru( '(cd %s && git merge --no-ff -m %s %s)', $this->getPath(), $message, $branch); if ($err) { throw new ArcanistUsageException("Merge failed!"); } } public function getFinalizedRevisionMessage() { return "You may now push this commit upstream, as appropriate (e.g. with ". "'git push', or 'git svn dcommit', or by printing and faxing it)."; } public function getCommitMessage($commit) { list($message) = $this->execxLocal( 'log -n1 --format=%C %s --', '%s%n%n%b', $commit); return $message; } public function loadWorkingCopyDifferentialRevisions( ConduitClient $conduit, array $query) { $messages = $this->getGitCommitLog(); if (!strlen($messages)) { return array(); } $parser = new ArcanistDiffParser(); $messages = $parser->parseDiff($messages); // First, try to find revisions by explicit revision IDs in commit messages. $reason_map = array(); $revision_ids = array(); foreach ($messages as $message) { $object = ArcanistDifferentialCommitMessage::newFromRawCorpus( $message->getMetadata('message')); if ($object->getRevisionID()) { $revision_ids[] = $object->getRevisionID(); $reason_map[$object->getRevisionID()] = $message->getCommitHash(); } } if ($revision_ids) { $results = $conduit->callMethodSynchronous( 'differential.query', $query + array( 'ids' => $revision_ids, )); foreach ($results as $key => $result) { $hash = substr($reason_map[$result['id']], 0, 16); $results[$key]['why'] = "Commit message for '{$hash}' has explicit 'Differential Revision'."; } return $results; } // If we didn't succeed, try to find revisions by hash. $hashes = array(); foreach ($this->getLocalCommitInformation() as $commit) { $hashes[] = array('gtcm', $commit['commit']); $hashes[] = array('gttr', $commit['tree']); } $results = $conduit->callMethodSynchronous( 'differential.query', $query + array( 'commitHashes' => $hashes, )); foreach ($results as $key => $result) { $results[$key]['why'] = "A git commit or tree hash in the commit range is already attached ". "to the Differential revision."; } return $results; } public function updateWorkingCopy() { $this->execxLocal('pull'); } public function getCommitSummary($commit) { if ($commit == self::GIT_MAGIC_ROOT_COMMIT) { return '(The Empty Tree)'; } list($summary) = $this->execxLocal( 'log -n 1 --format=%C %s', '%s', $commit); return trim($summary); } public function resolveBaseCommitRule($rule, $source) { list($type, $name) = explode(':', $rule, 2); switch ($type) { case 'git': $matches = null; if (preg_match('/^merge-base\((.+)\)$/', $name, $matches)) { list($err, $merge_base) = $this->execManualLocal( 'merge-base %s HEAD', $matches[1]); if (!$err) { $this->setBaseCommitExplanation( "it is the merge-base of '{$matches[1]}' and HEAD, as ". "specified by '{$rule}' in your {$source} 'base' ". "configuration."); return trim($merge_base); } } else if (preg_match('/^branch-unique\((.+)\)$/', $name, $matches)) { list($err, $merge_base) = $this->execManualLocal( 'merge-base %s HEAD', $matches[1]); if ($err) { return null; } $merge_base = trim($merge_base); list($commits) = $this->execxLocal( 'log --format=%C %s..HEAD --', '%H', $merge_base); $commits = array_filter(explode("\n", $commits)); if (!$commits) { return null; } $commits[] = $merge_base; $head_branch_count = null; foreach ($commits as $commit) { list($branches) = $this->execxLocal( 'branch --contains %s', $commit); $branches = array_filter(explode("\n", $branches)); if ($head_branch_count === null) { // If this is the first commit, it's HEAD. Count how many // branches it is on; we want to include commits on the same // number of branches. This covers a case where this branch // has sub-branches and we're running "arc diff" here again // for whatever reason. $head_branch_count = count($branches); } else if (count($branches) > $head_branch_count) { foreach ($branches as $key => $branch) { $branches[$key] = trim($branch, ' *'); } $branches = implode(', ', $branches); $this->setBaseCommitExplanation( "it is the first commit between '{$merge_base}' (the ". "merge-base of '{$matches[1]}' and HEAD) which is also ". "contained by another branch ({$branches})."); return $commit; } } } else { list($err) = $this->execManualLocal( 'cat-file -t %s', $name); if (!$err) { $this->setBaseCommitExplanation( "it is specified by '{$rule}' in your {$source} 'base' ". "configuration."); return $name; } } break; case 'arc': switch ($name) { case 'empty': $this->setBaseCommitExplanation( "you specified '{$rule}' in your {$source} 'base' ". "configuration."); return self::GIT_MAGIC_ROOT_COMMIT; case 'amended': $text = $this->getCommitMessage('HEAD'); $message = ArcanistDifferentialCommitMessage::newFromRawCorpus( $text); if ($message->getRevisionID()) { $this->setBaseCommitExplanation( "HEAD has been amended with 'Differential Revision:', ". "as specified by '{$rule}' in your {$source} 'base' ". "configuration."); return 'HEAD^'; } break; case 'upstream': list($err, $upstream) = $this->execManualLocal( "rev-parse --abbrev-ref --symbolic-full-name '@{upstream}'"); if (!$err) { list($upstream_merge_base) = $this->execxLocal( 'merge-base %s HEAD', $upstream); $this->setBaseCommitExplanation( "it is the merge-base of the upstream of the current branch ". "and HEAD, and matched the rule '{$rule}' in your {$source} ". "'base' configuration."); return $upstream_merge_base; } break; } default: return null; } return null; } } diff --git a/src/repository/api/ArcanistMercurialAPI.php b/src/repository/api/ArcanistMercurialAPI.php index 59915aec..a552481f 100644 --- a/src/repository/api/ArcanistMercurialAPI.php +++ b/src/repository/api/ArcanistMercurialAPI.php @@ -1,744 +1,749 @@ setCWD($this->getPath()); return $future; } public function getSourceControlSystemName() { return 'hg'; } public function getMetadataPath() { return $this->getPath('.hg'); } public function getSourceControlBaseRevision() { return $this->getCanonicalRevisionName($this->getRelativeCommit()); } public function getCanonicalRevisionName($string) { list($stdout) = $this->execxLocal( 'log -l 1 --template %s -r %s --', '{node}', $string); return $stdout; } public function getSourceControlPath() { return '/'; } public function getBranchName() { if (!$this->branch) { list($stdout) = $this->execxLocal('branch'); $this->branch = trim($stdout); } return $this->branch; } public function setRelativeCommit($commit) { try { $commit = $this->getCanonicalRevisionName($commit); } catch (Exception $ex) { throw new ArcanistUsageException( "Commit '{$commit}' is not a valid Mercurial commit identifier."); } $this->relativeCommit = $commit; $this->status = null; $this->localCommitInfo = null; return $this; } public function getRelativeCommit() { if (empty($this->relativeCommit)) { if ($this->getBaseCommitArgumentRules() || $this->getWorkingCopyIdentity()->getConfigFromAnySource('base')) { $base = $this->resolveBaseCommit(); if (!$base) { throw new ArcanistUsageException( "None of the rules in your 'base' configuration matched a valid ". "commit. Adjust rules or specify which commit you want to use ". "explicitly."); } $this->relativeCommit = $base; return $this->relativeCommit; } list($err, $stdout) = $this->execManualLocal( 'outgoing --branch %s --style default', $this->getBranchName()); if (!$err) { $logs = ArcanistMercurialParser::parseMercurialLog($stdout); } else { // Mercurial (in some versions?) raises an error when there's nothing // outgoing. $logs = array(); } if (!$logs) { $this->setBaseCommitExplanation( "you have no outgoing commits, so arc assumes you intend to submit ". "uncommitted changes in the working copy."); // In Mercurial, we support operations against uncommitted changes. $this->setRelativeCommit($this->getWorkingCopyRevision()); return $this->relativeCommit; } $outgoing_revs = ipull($logs, 'rev'); // This is essentially an implementation of a theoretical `hg merge-base` // command. $against = $this->getWorkingCopyRevision(); while (true) { // NOTE: The "^" and "~" syntaxes were only added in hg 1.9, which is // new as of July 2011, so do this in a compatible way. Also, "hg log" // and "hg outgoing" don't necessarily show parents (even if given an // explicit template consisting of just the parents token) so we need // to separately execute "hg parents". list($stdout) = $this->execxLocal( 'parents --style default --rev %s', $against); $parents_logs = ArcanistMercurialParser::parseMercurialLog($stdout); list($p1, $p2) = array_merge($parents_logs, array(null, null)); if ($p1 && !in_array($p1['rev'], $outgoing_revs)) { $against = $p1['rev']; break; } else if ($p2 && !in_array($p2['rev'], $outgoing_revs)) { $against = $p2['rev']; break; } else if ($p1) { $against = $p1['rev']; } else { // This is the case where you have a new repository and the entire // thing is outgoing; Mercurial literally accepts "--rev null" as // meaning "diff against the empty state". $against = 'null'; break; } } if ($against == 'null') { $this->setBaseCommitExplanation( "this is a new repository (all changes are outgoing)."); } else { $this->setBaseCommitExplanation( "it is the first commit reachable from the working copy state ". "which is not outgoing."); } $this->setRelativeCommit($against); } return $this->relativeCommit; } public function getLocalCommitInformation() { if ($this->localCommitInfo === null) { list($info) = $this->execxLocal( "log --template '%C' --rev %s --branch %s --", "{node}\1{rev}\1{author}\1{date|rfc822date}\1". "{branch}\1{tag}\1{parents}\1{desc}\2", '(ancestors(.) - ancestors('.$this->getRelativeCommit().'))', $this->getBranchName()); $logs = array_filter(explode("\2", $info)); $last_node = null; $futures = array(); $commits = array(); foreach ($logs as $log) { list($node, $rev, $author, $date, $branch, $tag, $parents, $desc) = explode("\1", $log); // NOTE: If a commit has only one parent, {parents} returns empty. // If it has two parents, {parents} returns revs and short hashes, not // full hashes. Try to avoid making calls to "hg parents" because it's // relatively expensive. $commit_parents = null; if (!$parents) { if ($last_node) { $commit_parents = array($last_node); } } if (!$commit_parents) { // We didn't get a cheap hit on previous commit, so do the full-cost // "hg parents" call. We can run these in parallel, at least. $futures[$node] = $this->execFutureLocal( "parents --template='{node}\\n' --rev %s", $node); } $commits[$node] = array( 'author' => $author, 'time' => strtotime($date), 'branch' => $branch, 'tag' => $tag, 'commit' => $node, 'rev' => $node, // TODO: Remove eventually. 'local' => $rev, 'parents' => $commit_parents, 'summary' => head(explode("\n", $desc)), 'message' => $desc, ); $last_node = $node; } foreach (Futures($futures)->limit(4) as $node => $future) { list($parents) = $future->resolvex(); $parents = array_filter(explode("\n", $parents)); $commits[$node]['parents'] = $parents; } // Put commits in newest-first order, to be consistent with Git and the // expected order of "hg log" and "git log" under normal circumstances. // The order of ancestors() is oldest-first. $commits = array_reverse($commits); $this->localCommitInfo = $commits; } return $this->localCommitInfo; } public function getBlame($path) { list($stdout) = $this->execxLocal( 'annotate -u -v -c --rev %s -- %s', $this->getRelativeCommit(), $path); $blame = array(); foreach (explode("\n", trim($stdout)) as $line) { if (!strlen($line)) { continue; } $matches = null; $ok = preg_match('/^\s*([^:]+?) [a-f0-9]{12}: (.*)$/', $line, $matches); if (!$ok) { throw new Exception("Unable to parse Mercurial blame line: {$line}"); } $revision = $matches[2]; $author = trim($matches[1]); $blame[] = array($author, $revision); } return $blame; } public function getWorkingCopyStatus() { if (!isset($this->status)) { // A reviewable revision spans multiple local commits in Mercurial, but // there is no way to get file change status across multiple commits, so // just take the entire diff and parse it to figure out what's changed. $diff = $this->getFullMercurialDiff(); if (!$diff) { $this->status = array(); return $this->status; } $parser = new ArcanistDiffParser(); $changes = $parser->parseDiff($diff); $status_map = array(); foreach ($changes as $change) { $flags = 0; switch ($change->getType()) { case ArcanistDiffChangeType::TYPE_ADD: case ArcanistDiffChangeType::TYPE_MOVE_HERE: case ArcanistDiffChangeType::TYPE_COPY_HERE: $flags |= self::FLAG_ADDED; break; case ArcanistDiffChangeType::TYPE_CHANGE: case ArcanistDiffChangeType::TYPE_COPY_AWAY: // Check for changes? $flags |= self::FLAG_MODIFIED; break; case ArcanistDiffChangeType::TYPE_DELETE: case ArcanistDiffChangeType::TYPE_MOVE_AWAY: case ArcanistDiffChangeType::TYPE_MULTICOPY: $flags |= self::FLAG_DELETED; break; } $status_map[$change->getCurrentPath()] = $flags; } list($stdout) = $this->execxLocal('status'); $working_status = ArcanistMercurialParser::parseMercurialStatus($stdout); foreach ($working_status as $path => $status) { if ($status & ArcanistRepositoryAPI::FLAG_UNTRACKED) { // If the file is untracked, don't mark it uncommitted. continue; } $status |= self::FLAG_UNCOMMITTED; if (!empty($status_map[$path])) { $status_map[$path] |= $status; } else { $status_map[$path] = $status; } } $this->status = $status_map; } return $this->status; } private function getDiffOptions() { $options = array( '--git', '-U'.$this->getDiffLinesOfContext(), ); return implode(' ', $options); } public function getRawDiffText($path) { $options = $this->getDiffOptions(); // NOTE: In Mercurial, "--rev x" means "diff between x and the working // copy state", while "--rev x..." means "diff between x and the working // copy commit" (i.e., from 'x' to '.'). The latter excludes any dirty // changes in the working copy. $range = $this->getRelativeCommit(); if (!$this->includeDirectoryStateInDiffs) { $range .= '...'; } list($stdout) = $this->execxLocal( 'diff %C --rev %s -- %s', $options, $range, $path); return $stdout; } public function getFullMercurialDiff() { return $this->getRawDiffText(''); } public function getOriginalFileData($path) { return $this->getFileDataAtRevision($path, $this->getRelativeCommit()); } public function getCurrentFileData($path) { return $this->getFileDataAtRevision( $path, $this->getWorkingCopyRevision()); } private function getFileDataAtRevision($path, $revision) { list($err, $stdout) = $this->execManualLocal( 'cat --rev %s -- %s', $revision, $path); if ($err) { // Assume this is "no file at revision", i.e. a deleted or added file. return null; } else { return $stdout; } } public function getWorkingCopyRevision() { return '.'; } public function isHistoryDefaultImmutable() { return true; } public function supportsAmend() { list($err, $stdout) = $this->execManualLocal('help commit'); if ($err) { return false; } else { return (strpos($stdout, "amend") !== false); } } public function supportsRelativeLocalCommits() { return true; } + public function setDefaultBaseCommit() { + $this->setRelativeCommit('.^'); + return $this; + } + public function hasLocalCommit($commit) { try { $this->getCanonicalRevisionName($commit); return true; } catch (Exception $ex) { return false; } } public function getCommitMessage($commit) { list($message) = $this->execxLocal( 'log --template={desc} --rev %s', $commit); return $message; } public function parseRelativeLocalCommit(array $argv) { if (count($argv) == 0) { return; } if (count($argv) != 1) { throw new ArcanistUsageException("Specify only one commit."); } $this->setBaseCommitExplanation("you explicitly specified it."); // This does the "hg id" call we need to normalize/validate the revision // identifier. $this->setRelativeCommit(reset($argv)); } public function getAllLocalChanges() { $diff = $this->getFullMercurialDiff(); if (!strlen(trim($diff))) { return array(); } $parser = new ArcanistDiffParser(); return $parser->parseDiff($diff); } public function supportsLocalBranchMerge() { return true; } public function performLocalBranchMerge($branch, $message) { if ($branch) { $err = phutil_passthru( '(cd %s && HGPLAIN=1 hg merge --rev %s && hg commit -m %s)', $this->getPath(), $branch, $message); } else { $err = phutil_passthru( '(cd %s && HGPLAIN=1 hg merge && hg commit -m %s)', $this->getPath(), $message); } if ($err) { throw new ArcanistUsageException("Merge failed!"); } } public function getFinalizedRevisionMessage() { return "You may now push this commit upstream, as appropriate (e.g. with ". "'hg push' or by printing and faxing it)."; } public function getCommitMessageLog() { list($stdout) = $this->execxLocal( "log --template '{node}\\2{desc}\\1' --rev %s --branch %s --", 'ancestors(.) - ancestors('.$this->getRelativeCommit().')', $this->getBranchName()); $map = array(); $logs = explode("\1", trim($stdout)); foreach (array_filter($logs) as $log) { list($node, $desc) = explode("\2", $log); $map[$node] = $desc; } return array_reverse($map); } public function loadWorkingCopyDifferentialRevisions( ConduitClient $conduit, array $query) { $messages = $this->getCommitMessageLog(); $parser = new ArcanistDiffParser(); // First, try to find revisions by explicit revision IDs in commit messages. $reason_map = array(); $revision_ids = array(); foreach ($messages as $node_id => $message) { $object = ArcanistDifferentialCommitMessage::newFromRawCorpus($message); if ($object->getRevisionID()) { $revision_ids[] = $object->getRevisionID(); $reason_map[$object->getRevisionID()] = $node_id; } } if ($revision_ids) { $results = $conduit->callMethodSynchronous( 'differential.query', $query + array( 'ids' => $revision_ids, )); foreach ($results as $key => $result) { $hash = substr($reason_map[$result['id']], 0, 16); $results[$key]['why'] = "Commit message for '{$hash}' has explicit 'Differential Revision'."; } return $results; } // Try to find revisions by hash. $hashes = array(); foreach ($this->getLocalCommitInformation() as $commit) { $hashes[] = array('hgcm', $commit['commit']); } if ($hashes) { // NOTE: In the case of "arc diff . --uncommitted" in a Mercurial working // copy with dirty changes, there may be no local commits. $results = $conduit->callMethodSynchronous( 'differential.query', $query + array( 'commitHashes' => $hashes, )); foreach ($results as $key => $hash) { $results[$key]['why'] = "A mercurial commit hash in the commit range is already attached ". "to the Differential revision."; } return $results; } return array(); } public function updateWorkingCopy() { $this->execxLocal('up'); } public function amendCommit($message) { $tmp_file = new TempFile(); Filesystem::writeFile($tmp_file, $message); $this->execxLocal( 'commit --amend -l %s', $tmp_file); } public function setIncludeDirectoryStateInDiffs($include) { $this->includeDirectoryStateInDiffs = $include; return $this; } public function getCommitSummary($commit) { if ($commit == 'null') { return '(The Empty Void)'; } list($summary) = $this->execxLocal( 'log --template {desc} --limit 1 --rev %s', $commit); $summary = head(explode("\n", $summary)); return trim($summary); } public function resolveBaseCommitRule($rule, $source) { list($type, $name) = explode(':', $rule, 2); switch ($type) { case 'hg': $matches = null; if (preg_match('/^gca\((.+)\)$/', $name, $matches)) { list($err, $merge_base) = $this->execManualLocal( 'log --template={node} --rev %s', sprintf('ancestor(., %s)', $matches[1])); if (!$err) { $this->setBaseCommitExplanation( "it is the greatest common ancestor of '{$matches[1]}' and ., as". "specified by '{$rule}' in your {$source} 'base' ". "configuration."); return trim($merge_base); } } else { list($err) = $this->execManualLocal( 'id -r %s', $name); if (!$err) { $this->setBaseCommitExplanation( "it is specified by '{$rule}' in your {$source} 'base' ". "configuration."); return $name; } } break; case 'arc': switch ($name) { case 'empty': $this->setBaseCommitExplanation( "you specified '{$rule}' in your {$source} 'base' ". "configuration."); return 'null'; case 'outgoing': list($err, $outgoing_base) = $this->execManualLocal( 'log --template={node} --rev %s', 'limit(reverse(ancestors(.) - outgoing()), 1)' ); if (!$err) { $this->setBaseCommitExplanation( "it is the first ancestor of the working copy that is not ". "outgoing, and it matched the rule {$rule} in your {$source} ". "'base' configuration."); return trim($outgoing_base); } case 'amended': $text = $this->getCommitMessage('.'); $message = ArcanistDifferentialCommitMessage::newFromRawCorpus( $text); if ($message->getRevisionID()) { $this->setBaseCommitExplanation( "'.' has been amended with 'Differential Revision:', ". "as specified by '{$rule}' in your {$source} 'base' ". "configuration."); // NOTE: This should be safe because Mercurial doesn't support // amend until 2.2. return '.^'; } break; case 'bookmark': $revset = 'limit('. ' sort('. ' (ancestors(.) and bookmark() - .) or'. ' (ancestors(.) - outgoing()), '. ' -rev),'. '1)'; list($err, $bookmark_base) = $this->execManualLocal( 'log --template={node} --rev %s', $revset); if (!$err) { $this->setBaseCommitExplanation( "it is the first ancestor of . that either has a bookmark, or ". "is already in the remote and it matched the rule {$rule} in ". "your {$source} 'base' configuration"); return trim($bookmark_base); } } break; default: return null; } return null; } public function getSubversionInfo() { $info = array(); $base_path = null; $revision = null; list($err, $raw_info) = $this->execManualLocal('svn info'); if (!$err) { foreach (explode("\n", trim($raw_info)) as $line) { list($key, $value) = explode(': ', $line, 2); switch ($key) { case 'URL': $info['base_path'] = $value; $base_path = $value; break; case 'Repository UUID': $info['uuid'] = $value; break; case 'Revision': $revision = $value; break; default: break; } } if ($base_path && $revision) { $info['base_revision'] = $base_path.'@'.$revision; } } return $info; } public function getActiveBookmark() { list($raw_output) = $this->execxLocal('bookmarks'); $raw_output = trim($raw_output); if ($raw_output !== 'no bookmarks set') { foreach (explode("\n", $raw_output) as $line) { $line = trim($line); if ('*' === $line[0]) { return idx(explode(' ', $line, 3), 1); } } } return null; } } diff --git a/src/repository/api/ArcanistRepositoryAPI.php b/src/repository/api/ArcanistRepositoryAPI.php index d96afd7c..4524ecd7 100644 --- a/src/repository/api/ArcanistRepositoryAPI.php +++ b/src/repository/api/ArcanistRepositoryAPI.php @@ -1,427 +1,442 @@ diffLinesOfContext; } public function setDiffLinesOfContext($lines) { $this->diffLinesOfContext = $lines; return $this; } public function getWorkingCopyIdentity() { return $this->workingCopyIdentity; } public static function newAPIFromWorkingCopyIdentity( ArcanistWorkingCopyIdentity $working_copy) { $root = $working_copy->getProjectRoot(); if (!$root) { throw new ArcanistUsageException( "There is no readable '.arcconfig' file in the working directory or ". "any parent directory. Create an '.arcconfig' file to configure arc."); } // check if we're in an svn working copy list($err) = exec_manual('svn info'); if (!$err) { $api = new ArcanistSubversionAPI($root); $api->workingCopyIdentity = $working_copy; return $api; } if (Filesystem::pathExists($root.'/.hg')) { $api = new ArcanistMercurialAPI($root); $api->workingCopyIdentity = $working_copy; return $api; } $git_root = self::discoverGitBaseDirectory($root); if ($git_root) { if (!Filesystem::pathsAreEquivalent($root, $git_root)) { throw new ArcanistUsageException( "'.arcconfig' file is located at '{$root}', but working copy root ". "is '{$git_root}'. Move '.arcconfig' file to the working copy root."); } $api = new ArcanistGitAPI($root); $api->workingCopyIdentity = $working_copy; return $api; } throw new ArcanistUsageException( "The current working directory is not part of a working copy for a ". "supported version control system (svn, git or mercurial)."); } public function __construct($path) { $this->path = $path; } public function getPath($to_file = null) { if ($to_file !== null) { return $this->path.DIRECTORY_SEPARATOR. ltrim($to_file, DIRECTORY_SEPARATOR); } else { return $this->path.DIRECTORY_SEPARATOR; } } public function getUntrackedChanges() { return $this->getWorkingCopyFilesWithMask(self::FLAG_UNTRACKED); } public function getUnstagedChanges() { return $this->getWorkingCopyFilesWithMask(self::FLAG_UNSTAGED); } public function getUncommittedChanges() { return $this->getWorkingCopyFilesWithMask(self::FLAG_UNCOMMITTED); } public function getMergeConflicts() { return $this->getWorkingCopyFilesWithMask(self::FLAG_CONFLICT); } public function getIncompleteChanges() { return $this->getWorkingCopyFilesWithMask(self::FLAG_INCOMPLETE); } private function getWorkingCopyFilesWithMask($mask) { $match = array(); foreach ($this->getWorkingCopyStatus() as $file => $flags) { if ($flags & $mask) { $match[] = $file; } } return $match; } private static function discoverGitBaseDirectory($root) { try { // NOTE: This awkward construction is to make sure things work on Windows. $future = new ExecFuture('git rev-parse --show-cdup'); $future->setCWD($root); list($stdout) = $future->resolvex(); return Filesystem::resolvePath(rtrim($stdout, "\n"), $root); } catch (CommandException $ex) { if (preg_match('/^fatal: Not a git repository/', $ex->getStdErr())) { return null; } throw $ex; } } abstract public function getBlame($path); abstract public function getWorkingCopyStatus(); abstract public function getRawDiffText($path); abstract public function getOriginalFileData($path); abstract public function getCurrentFileData($path); abstract public function getLocalCommitInformation(); abstract public function getSourceControlBaseRevision(); abstract public function getCanonicalRevisionName($string); abstract public function isHistoryDefaultImmutable(); abstract public function supportsAmend(); abstract public function supportsRelativeLocalCommits(); abstract public function getWorkingCopyRevision(); abstract public function updateWorkingCopy(); abstract public function getMetadataPath(); abstract public function loadWorkingCopyDifferentialRevisions( ConduitClient $conduit, array $query); + /** + * Set the base commit to a reasonable default value so that working copy + * status checks can do something meaningful and won't invoke configured + * 'base' rules. + * + * This is primarily useful for workflows which do not operate on commit + * ranges but need to verify the working copy is not dirty, like "amend", + * "upgrade" and "patch". + * + * @return this + */ + public function setDefaultBaseCommit() { + throw new ArcanistCapabilityNotSupportedException($this); + } + public function amendCommit($message) { throw new ArcanistCapabilityNotSupportedException($this); } public function getAllBranches() { // TODO: Implement for Mercurial/SVN and make abstract. return array(); } public function hasLocalCommit($commit) { throw new ArcanistCapabilityNotSupportedException($this); } public function getCommitMessage($commit) { throw new ArcanistCapabilityNotSupportedException($this); } public function parseRelativeLocalCommit(array $argv) { throw new ArcanistCapabilityNotSupportedException($this); } public function getCommitSummary($commit) { throw new ArcanistCapabilityNotSupportedException($this); } public function getAllLocalChanges() { throw new ArcanistCapabilityNotSupportedException($this); } abstract public function supportsLocalBranchMerge(); public function performLocalBranchMerge($branch, $message) { throw new ArcanistCapabilityNotSupportedException($this); } public function getFinalizedRevisionMessage() { throw new ArcanistCapabilityNotSupportedException($this); } public function execxLocal($pattern /* , ... */) { $args = func_get_args(); return $this->buildLocalFuture($args)->resolvex(); } public function execManualLocal($pattern /* , ... */) { $args = func_get_args(); return $this->buildLocalFuture($args)->resolve(); } public function execFutureLocal($pattern /* , ... */) { $args = func_get_args(); return $this->buildLocalFuture($args); } abstract protected function buildLocalFuture(array $argv); /* -( Scratch Files )------------------------------------------------------ */ /** * Try to read a scratch file, if it exists and is readable. * * @param string Scratch file name. * @return mixed String for file contents, or false for failure. * @task scratch */ public function readScratchFile($path) { $full_path = $this->getScratchFilePath($path); if (!$full_path) { return false; } if (!Filesystem::pathExists($full_path)) { return false; } try { $result = Filesystem::readFile($full_path); } catch (FilesystemException $ex) { return false; } return $result; } /** * Try to write a scratch file, if there's somewhere to put it and we can * write there. * * @param string Scratch file name to write. * @param string Data to write. * @return bool True on success, false on failure. * @task scratch */ public function writeScratchFile($path, $data) { $dir = $this->getScratchFilePath(''); if (!$dir) { return false; } if (!Filesystem::pathExists($dir)) { try { Filesystem::createDirectory($dir); } catch (Exception $ex) { return false; } } try { Filesystem::writeFile($this->getScratchFilePath($path), $data); } catch (FilesystemException $ex) { return false; } return true; } /** * Try to remove a scratch file. * * @param string Scratch file name to remove. * @return bool True if the file was removed successfully. * @task scratch */ public function removeScratchFile($path) { $full_path = $this->getScratchFilePath($path); if (!$full_path) { return false; } try { Filesystem::remove($full_path); } catch (FilesystemException $ex) { return false; } return true; } /** * Get a human-readable description of the scratch file location. * * @param string Scratch file name. * @return mixed String, or false on failure. * @task scratch */ public function getReadableScratchFilePath($path) { $full_path = $this->getScratchFilePath($path); if ($full_path) { return Filesystem::readablePath( $full_path, $this->getPath()); } else { return false; } } /** * Get the path to a scratch file, if possible. * * @param string Scratch file name. * @return mixed File path, or false on failure. * @task scratch */ public function getScratchFilePath($path) { $new_scratch_path = Filesystem::resolvePath( 'arc', $this->getMetadataPath()); static $checked = false; if (!$checked) { $checked = true; $old_scratch_path = $this->getPath('.arc'); // we only want to do the migration once // unfortunately, people have checked in .arc directories which // means that the old one may get recreated after we delete it if (Filesystem::pathExists($old_scratch_path) && !Filesystem::pathExists($new_scratch_path)) { Filesystem::createDirectory($new_scratch_path); $existing_files = Filesystem::listDirectory($old_scratch_path, true); foreach ($existing_files as $file) { $new_path = Filesystem::resolvePath($file, $new_scratch_path); $old_path = Filesystem::resolvePath($file, $old_scratch_path); Filesystem::writeFile( $new_path, Filesystem::readFile($old_path)); } Filesystem::remove($old_scratch_path); } } return Filesystem::resolvePath($path, $new_scratch_path); } /* -( Base Commits )------------------------------------------------------- */ public function getBaseCommitExplanation() { return $this->baseCommitExplanation; } public function setBaseCommitExplanation($explanation) { $this->baseCommitExplanation = $explanation; return $this; } public function resolveBaseCommitRule($rule, $source) { return null; } public function setBaseCommitArgumentRules($base_commit_argument_rules) { $this->baseCommitArgumentRules = $base_commit_argument_rules; return $this; } public function getBaseCommitArgumentRules() { return $this->baseCommitArgumentRules; } public function resolveBaseCommit() { $working_copy = $this->getWorkingCopyIdentity(); $global_config = ArcanistBaseWorkflow::readGlobalArcConfig(); $system_config = ArcanistBaseWorkflow::readSystemArcConfig(); $parser = new ArcanistBaseCommitParser($this); $commit = $parser->resolveBaseCommit( array( 'args' => $this->getBaseCommitArgumentRules(), 'local' => $working_copy->getLocalConfig('base', ''), 'project' => $working_copy->getConfig('base', ''), 'global' => idx($global_config, 'base', ''), 'system' => idx($system_config, 'base', ''), )); return $commit; } } diff --git a/src/workflow/ArcanistAmendWorkflow.php b/src/workflow/ArcanistAmendWorkflow.php index 31f5a82b..947a8be9 100644 --- a/src/workflow/ArcanistAmendWorkflow.php +++ b/src/workflow/ArcanistAmendWorkflow.php @@ -1,209 +1,207 @@ array( 'help' => "Show the amended commit message, without modifying the working copy." ), 'revision' => array( 'param' => 'revision_id', 'help' => "Amend a specific revision. If you do not specify a revision, ". "arc will look in the commit message at HEAD.", ), ); } public function run() { $is_show = $this->getArgument('show'); $repository_api = $this->getRepositoryAPI(); if (!$is_show) { if (!$repository_api->supportsAmend()) { throw new ArcanistUsageException( "You may only run 'arc amend' in a git or hg (version ". "2.2 or newer) working copy."); } if ($this->isHistoryImmutable()) { throw new ArcanistUsageException( "This project is marked as adhering to a conservative history ". "mutability doctrine (having an immutable local history), which ". "precludes amending commit messages. You can use 'arc merge' to ". "merge feature branches instead."); } if ($repository_api->getUncommittedChanges()) { throw new ArcanistUsageException( "You have uncommitted changes in this branch. Stage and commit (or ". "revert) them before proceeding."); } } $revision_id = null; if ($this->getArgument('revision')) { $revision_id = $this->normalizeRevisionID($this->getArgument('revision')); } - if ($repository_api instanceof ArcanistGitAPI) { - $repository_api->setRelativeCommit('HEAD^'); - } else if ($repository_api instanceof ArcanistMercurialAPI) { - $repository_api->setRelativeCommit('.^'); + if ($repository_api->supportsRelativeLocalCommits()) { + $repository_api->setDefaultBaseCommit(); } $in_working_copy = $repository_api->loadWorkingCopyDifferentialRevisions( $this->getConduit(), array( 'authors' => array($this->getUserPHID()), 'status' => 'status-any', )); $in_working_copy = ipull($in_working_copy, null, 'id'); if (!$revision_id) { if (count($in_working_copy) == 0) { throw new ArcanistUsageException( "No revision specified with '--revision', and no revisions found ". "in the working copy. Use '--revision ' to specify which ". "revision you want to amend."); } else if (count($in_working_copy) > 1) { $message = "More than one revision was found in the working copy:\n". $this->renderRevisionList($in_working_copy)."\n". "Use '--revision ' to specify which revision you want to ". "amend."; throw new ArcanistUsageException($message); } else { $revision_id = key($in_working_copy); } } $conduit = $this->getConduit(); try { $message = $conduit->callMethodSynchronous( 'differential.getcommitmessage', array( 'revision_id' => $revision_id, 'edit' => false, ) ); } catch (ConduitClientException $ex) { if (strpos($ex->getMessage(), 'ERR_NOT_FOUND') === false) { throw $ex; } else { throw new ArcanistUsageException( "Revision D{$revision_id} does not exist." ); } } $revision = $conduit->callMethodSynchronous( 'differential.query', array( 'ids' => array($revision_id), )); if (empty($revision)) { throw new Exception( "Failed to lookup information for 'D{$revision_id}'!"); } $revision = head($revision); $revision_title = $revision['title']; if (!$is_show) { if ($revision_id && empty($in_working_copy[$revision_id])) { $ok = phutil_console_confirm( "The revision 'D{$revision_id}' does not appear to be in the ". "working copy. Are you sure you want to amend HEAD with the ". "commit message for 'D{$revision_id}: {$revision_title}'?"); if (!$ok) { throw new ArcanistUserAbortException(); } } } if ($is_show) { echo $message."\n"; } else { echo phutil_console_format( "Amending commit message to reflect revision **%s**.\n", "D{$revision_id}: {$revision_title}"); $repository_api->amendCommit($message); $mark_workflow = $this->buildChildWorkflow( 'close-revision', array( '--finalize', $revision_id, )); $mark_workflow->run(); } return 0; } protected function getSupportedRevisionControlSystems() { return array('git', 'hg'); } } diff --git a/src/workflow/ArcanistDiffWorkflow.php b/src/workflow/ArcanistDiffWorkflow.php index f27954a7..175a4a84 100644 --- a/src/workflow/ArcanistDiffWorkflow.php +++ b/src/workflow/ArcanistDiffWorkflow.php @@ -1,2360 +1,2337 @@ isRawDiffSource(); } public function requiresConduit() { return true; } public function requiresAuthentication() { return true; } public function requiresRepositoryAPI() { if (!$this->isRawDiffSource()) { return true; } if ($this->getArgument('use-commit-message')) { return true; } return false; } public function getDiffID() { return $this->diffID; } public function getArguments() { $arguments = array( 'message' => array( 'short' => 'm', 'param' => 'message', 'help' => "When updating a revision, use the specified message instead of ". "prompting.", ), 'message-file' => array( 'short' => 'F', 'param' => 'file', 'paramtype' => 'file', 'help' => 'When creating a revision, read revision information '. 'from this file.', ), 'use-commit-message' => array( 'supports' => array( 'git', // TODO: Support mercurial. ), 'short' => 'C', 'param' => 'commit', 'help' => 'Read revision information from a specific commit.', 'conflicts' => array( 'only' => null, 'preview' => null, 'update' => null, ), ), '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.", ), 'raw' => array( 'help' => "Read diff from stdin, not from the working copy. This disables ". "many Arcanist/Phabricator features which depend on having access ". "to the working copy.", 'conflicts' => array( 'less-context' => null, 'apply-patches' => '--raw disables lint.', 'never-apply-patches' => '--raw disables lint.', 'lintall' => '--raw disables lint.', 'create' => '--raw and --create both need stdin. '. 'Use --raw-command.', 'edit' => '--raw and --edit both need stdin. '. 'Use --raw-command.', 'raw-command' => null, ), ), 'raw-command' => array( 'param' => 'command', 'help' => "Generate diff by executing a specified command, not from the ". "working copy. This disables many Arcanist/Phabricator features ". "which depend on having access to the working copy.", 'conflicts' => array( 'less-context' => null, 'apply-patches' => '--raw-command disables lint.', 'never-apply-patches' => '--raw-command disables lint.', 'lintall' => '--raw-command disables lint.', ), ), 'create' => array( 'help' => "Always create a new revision.", 'conflicts' => array( 'edit' => '--create can not be used with --edit.', 'only' => '--create can not be used with --only.', 'preview' => '--create can not be used with --preview.', 'update' => '--create can not be used with --update.', ), ), 'update' => array( 'param' => 'revision_id', 'help' => "Always update a specific revision.", ), 'nounit' => array( 'help' => "Do not run unit tests.", ), 'nolint' => array( 'help' => "Do not run lint.", 'conflicts' => array( 'lintall' => '--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. See also --preview.", 'conflicts' => array( 'preview' => null, 'message' => '--only does not affect revisions.', 'edit' => '--only does not affect revisions.', 'lintall' => '--only suppresses lint.', 'apply-patches' => '--only suppresses lint.', 'never-apply-patches' => '--only suppresses lint.', ), ), 'preview' => array( '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.', ), ), 'encoding' => array( 'param' => 'encoding', 'help' => "Attempt to convert non UTF-8 hunks into specified encoding.", ), 'allow-untracked' => array( 'help' => "Skip checks for untracked files in the working copy.", ), 'excuse' => array( 'param' => 'excuse', 'help' => 'Provide a prepared in advance excuse for any lints/tests'. ' shall they fail.', ), '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, ), ), '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, ), ), 'amend-all' => array( 'help' => 'When linting git repositories, amend HEAD with all patches '. 'suggested by lint without prompting.', 'passthru' => array( 'lint' => true, ), ), 'amend-autofixes' => array( 'help' => 'When linting git repositories, amend HEAD with autofix '. 'patches suggested by lint without prompting.', 'passthru' => array( 'lint' => true, ), ), 'json' => array( 'help' => 'Emit machine-readable JSON. EXPERIMENTAL! Probably does not work!', ), 'no-amend' => array( 'help' => 'Never amend commits in the working copy.', ), 'uncommitted' => array( 'help' => 'Suppress warning about uncommitted changes.', 'supports' => array( 'hg', ), ), 'verbatim' => array( 'help' => 'When creating a revision, try to use the working copy '. 'commit message verbatim, without prompting to edit it. '. 'When updating a revision, update some fields from the '. 'local commit message.', 'supports' => array( 'hg', 'git', ), 'conflicts' => array( 'use-commit-message' => true, 'update' => true, 'only' => true, 'preview' => true, 'raw' => true, 'raw-command' => true, 'message-file' => true, ), ), 'reviewers' => array( 'param' => 'usernames', 'help' => 'When creating a revision, add reviewers.', 'conflicts' => array( 'only' => true, 'preview' => true, 'update' => true, ), ), 'cc' => array( 'param' => 'usernames', 'help' => 'When creating a revision, add CCs.', 'conflicts' => array( 'only' => true, 'preview' => true, 'update' => true, ), ), 'skip-binaries' => array( 'help' => 'Do not upload binaries (like images).', ), 'ignore-unsound-tests' => array( 'help' => 'Ignore unsound test failures without prompting.', ), 'base' => array( 'param' => 'rules', 'help' => 'Additional rules for determining base revision.', 'nosupport' => array( 'svn' => 'Subversion does not use base commits.', ), 'supports' => array('git', 'hg'), ), 'no-diff' => array( 'help' => 'Only run lint and unit tests. Intended for internal use.', ), 'background' => array( 'param' => 'bool', 'help' => 'Run lint and unit tests on background. '. '"0" to disable, "1" to enable (default).', ), '*' => 'paths', ); if (phutil_is_windows()) { unset($arguments['background']); } return $arguments; } public function isRawDiffSource() { return $this->getArgument('raw') || $this->getArgument('raw-command'); } public function run() { $this->console = PhutilConsole::getConsole(); $this->runRepositoryAPISetup(); if ($this->getArgument('no-diff')) { $this->removeScratchFile('diff-result.json'); $data = $this->runLintUnit(); $this->writeScratchJSONFile('diff-result.json', $data); return 0; } $this->runDiffSetupBasics(); $background = $this->getArgument('background', true); if ($this->isRawDiffSource() || phutil_is_windows()) { $background = false; } if ($background) { $argv = $this->getPassedArguments(); if (!PhutilConsoleFormatter::getDisableANSI()) { array_unshift($argv, '--ansi'); } $lint_unit = new ExecFuture( 'php %s --recon diff --no-diff %Ls', phutil_get_library_root('arcanist').'/../scripts/arcanist.php', $argv); $lint_unit->write('', true); $lint_unit->start(); } $commit_message = $this->buildCommitMessage(); $this->dispatchEvent( ArcanistEventType::TYPE_DIFF_DIDBUILDMESSAGE, array()); if (!$this->shouldOnlyCreateDiff()) { $revision = $this->buildRevisionFromCommitMessage($commit_message); } if ($background) { $server = new PhutilConsoleServer(); $server->addExecFutureClient($lint_unit); $server->run(); list($err) = $lint_unit->resolve(); $data = $this->readScratchJSONFile('diff-result.json'); if ($err || !$data) { return 1; } } else { $data = $this->runLintUnit(); } $lint_result = $data['lintResult']; $this->lintExcuse = $data['lintExcuse']; $this->unresolvedLint = $data['unresolvedLint']; $this->postponedLinters = $data['postponedLinters']; $unit_result = $data['unitResult']; $this->unitExcuse = $data['unitExcuse']; $this->testResults = $data['testResults']; $changes = $this->generateChanges(); if (!$changes) { throw new ArcanistUsageException( "There are no changes to generate a diff from!"); } $diff_spec = array( 'changes' => mpull($changes, 'toDictionary'), 'lintStatus' => $this->getLintStatus($lint_result), 'unitStatus' => $this->getUnitStatus($unit_result), ) + $this->buildDiffSpecification(); $conduit = $this->getConduit(); $diff_info = $conduit->callMethodSynchronous( 'differential.creatediff', $diff_spec); $this->diffID = $diff_info['diffid']; $event = $this->dispatchEvent( ArcanistEventType::TYPE_DIFF_WASCREATED, array( 'diffID' => $diff_info['diffid'], 'lintResult' => $lint_result, 'unitResult' => $unit_result, )); $this->updateLintDiffProperty(); $this->updateUnitDiffProperty(); $this->updateLocalDiffProperty(); $this->resolveDiffPropertyUpdates(); $output_json = $this->getArgument('json'); if ($this->shouldOnlyCreateDiff()) { if (!$output_json) { echo phutil_console_format( "Created a new Differential diff:\n". " **Diff URI:** __%s__\n\n", $diff_info['uri']); } else { $human = ob_get_clean(); echo json_encode(array( 'diffURI' => $diff_info['uri'], 'diffID' => $this->getDiffID(), 'human' => $human, ))."\n"; ob_start(); } } else { $revision['diffid'] = $this->getDiffID(); if ($commit_message->getRevisionID()) { $future = $conduit->callMethod( 'differential.updaterevision', $revision); $result = $future->resolve(); foreach (array('edit-messages.json', 'update-messages.json') as $file) { $messages = $this->readScratchJSONFile($file); unset($messages[$revision['id']]); $this->writeScratchJSONFile($file, $messages); } echo "Updated an existing Differential revision:\n"; } else { $revision['user'] = $this->getUserPHID(); $revision = $this->dispatchWillCreateRevisionEvent($revision); $result = $conduit->callMethodSynchronous( 'differential.createrevision', $revision); $revised_message = $conduit->callMethodSynchronous( 'differential.getcommitmessage', array( 'revision_id' => $result['revisionid'], )); if ($this->shouldAmend()) { $repository_api = $this->getRepositoryAPI(); if ($repository_api->supportsAmend()) { echo "Updating commit message...\n"; $repository_api->amendCommit($revised_message); } else { echo "Commit message was not amended. Amending commit message is ". "only supported in git and hg (version 2.2 or newer)"; } } 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"; } if ($output_json) { ob_get_clean(); } $this->removeScratchFile('create-message'); return 0; } private function runRepositoryAPISetup() { if (!$this->requiresRepositoryAPI()) { return; } $repository_api = $this->getRepositoryAPI(); if ($this->getArgument('less-context')) { $repository_api->setDiffLinesOfContext(3); } $repository_api->setBaseCommitArgumentRules( $this->getArgument('base', '')); if ($repository_api->supportsRelativeLocalCommits()) { // Parse the relative commit as soon as we can, to avoid generating // caches we need to drop later and expensive discovery operations // (particularly in Mercurial). $relative = $this->getArgument('paths'); if ($relative) { $repository_api->parseRelativeLocalCommit($relative); } } } private function runDiffSetupBasics() { $output_json = $this->getArgument('json'); if ($output_json) { // TODO: We should move this to a higher-level and put an indirection // layer between echoing stuff and stdout. ob_start(); } if ($this->requiresWorkingCopy()) { try { $this->requireCleanWorkingCopy(); } catch (ArcanistUncommittedChangesException $ex) { if ($repository_api instanceof ArcanistMercurialAPI) { // Some Mercurial users prefer to use it like SVN, where they don't // commit changes before sending them for review. This would be a // pretty bad workflow in Git, but Mercurial users are significantly // more expert at change management. $use_dirty_changes = false; if ($this->getArgument('uncommitted')) { // OK. } else { $ok = phutil_console_confirm( "You have uncommitted changes in your working copy. You can ". "include them in the diff, or abort and deal with them. (Use ". "'--uncommitted' to include them and skip this prompt.) ". "Do you want to include uncommitted changes in the diff?"); if (!$ok) { throw $ex; } } $repository_api->setIncludeDirectoryStateInDiffs(true); $this->haveUncommittedChanges = true; } else { throw $ex; } } } } private function buildRevisionFromCommitMessage( ArcanistDifferentialCommitMessage $message) { $conduit = $this->getConduit(); $revision_id = $message->getRevisionID(); $revision = array( 'fields' => $message->getFields(), ); if ($revision_id) { // With '--verbatim', pass the (possibly modified) local fields. This // allows the user to edit some fields (like "title" and "summary") // locally without '--edit' and have changes automatically synchronized. // Without '--verbatim', we do not update the revision to reflect local // commit message changes. if ($this->getArgument('verbatim')) { $use_fields = $message->getFields(); } else { $use_fields = array(); } $should_edit = $this->getArgument('edit'); $edit_messages = $this->readScratchJSONFile('edit-messages.json'); $remote_corpus = idx($edit_messages, $revision_id); if (!$should_edit || !$remote_corpus || $use_fields) { if ($this->commitMessageFromRevision) { $remote_corpus = $this->commitMessageFromRevision; } else { $remote_corpus = $conduit->callMethodSynchronous( 'differential.getcommitmessage', array( 'revision_id' => $revision_id, 'edit' => 'edit', 'fields' => $use_fields, )); } } if ($should_edit) { $edited = $this->newInteractiveEditor($remote_corpus) ->setName('differential-edit-revision-info') ->editInteractively(); if ($edited != $remote_corpus) { $remote_corpus = $edited; $edit_messages[$revision_id] = $remote_corpus; $this->writeScratchJSONFile('edit-messages.json', $edit_messages); } } if ($this->commitMessageFromRevision == $remote_corpus) { $new_message = $message; } else { $new_message = ArcanistDifferentialCommitMessage::newFromRawCorpus( $remote_corpus); $new_message->pullDataFromConduit($conduit); } $revision['fields'] = $new_message->getFields(); $revision['id'] = $revision_id; $this->revisionID = $revision_id; $revision['message'] = $this->getArgument('message'); if (!strlen($revision['message'])) { $update_messages = $this->readScratchJSONFile('update-messages.json'); $update_messages[$revision_id] = $this->getUpdateMessage( $revision['fields'], idx($update_messages, $revision_id)); $revision['message'] = ArcanistCommentRemover::removeComments( $update_messages[$revision_id]); if (!strlen(trim($revision['message']))) { throw new ArcanistUserAbortException(); } $this->writeScratchJSONFile('update-messages.json', $update_messages); } } return $revision; } protected function shouldOnlyCreateDiff() { if ($this->getArgument('create')) { return false; } if ($this->getArgument('update')) { return false; } if ($this->getArgument('use-commit-message')) { return false; } if ($this->isRawDiffSource()) { return true; } return $this->getArgument('preview') || $this->getArgument('only'); } private function generateAffectedPaths() { if ($this->isRawDiffSource()) { return array(); } $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 if ($repository_api->supportsRelativeLocalCommits()) { $paths = $repository_api->getWorkingCopyStatus(); } else { throw new Exception("Unknown VCS!"); } foreach ($paths as $path => $mask) { if ($mask & ArcanistRepositoryAPI::FLAG_UNTRACKED) { unset($paths[$path]); } } return $paths; } protected function generateChanges() { $parser = $this->newDiffParser(); $is_raw = $this->isRawDiffSource(); if ($is_raw) { if ($this->getArgument('raw')) { fwrite(STDERR, "Reading diff from stdin...\n"); $raw_diff = file_get_contents('php://stdin'); } else if ($this->getArgument('raw-command')) { list($raw_diff) = execx($this->getArgument('raw-command')); } else { throw new Exception("Unknown raw diff source."); } $changes = $parser->parseDiff($raw_diff); foreach ($changes as $key => $change) { // Remove "message" changes, e.g. from "git show". if ($change->getType() == ArcanistDiffChangeType::TYPE_MESSAGE) { unset($changes[$key]); } } return $changes; } $repository_api = $this->getRepositoryAPI(); 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); $revlist = array(); foreach ($bases as $path => $baserev) { $revlist[] = " Revision {$baserev}, {$path}"; } $revlist = implode("\n", $revlist); foreach ($bases as $path => $baserev) { if ($baserev !== $rev) { 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)) { throw new ArcanistUsageException( "No changes found. (Did you specify the wrong commit range?)"); } $changes = $parser->parseDiff($diff); } else if ($repository_api instanceof ArcanistMercurialAPI) { $diff = $repository_api->getFullMercurialDiff(); if (!strlen($diff)) { throw new ArcanistUsageException( "No changes found. (Did you specify the wrong commit range?)"); } $changes = $parser->parseDiff($diff); } else { throw new Exception("Repository API is not supported."); } if (count($changes) > 250) { $count = number_format(count($changes)); $link = "http://www.phabricator.com/docs/phabricator/article/". "Differential_User_Guide_Large_Changes.html"; $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. See {$link} for information about reviewing big ". "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->getArgument('less-context')) { $byte_warning .= " 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 ". "\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."); } } } } $try_encoding = nonempty($this->getArgument('encoding'), null); $utf8_problems = array(); foreach ($changes as $change) { foreach ($change->getHunks() as $hunk) { $corpus = $hunk->getCorpus(); if (!phutil_is_utf8($corpus)) { // If this corpus is heuristically binary, don't try to convert it. // mb_check_encoding() and mb_convert_encoding() are both very very // liberal about what they're willing to process. $is_binary = ArcanistDiffUtils::isHeuristicBinaryFile($corpus); if (!$is_binary) { if (!$try_encoding) { try { $try_encoding = $this->getRepositoryEncoding(); } catch (ConduitClientException $e) { if ($e->getErrorCode() == 'ERR-BAD-ARCANIST-PROJECT') { echo phutil_console_wrap( "Lookup of encoding in arcanist project failed\n". $e->getMessage()); } else { throw $e; } } } if ($try_encoding) { $corpus = phutil_utf8_convert($corpus, 'UTF-8', $try_encoding); $name = $change->getCurrentPath(); if (phutil_is_utf8($corpus)) { $this->writeStatusMessage( "Converted a '{$name}' hunk from '{$try_encoding}' ". "to UTF-8.\n"); $hunk->setCorpus($corpus); continue; } } } $utf8_problems[] = $change; break; } } } // If there are non-binary files which aren't valid UTF-8, warn the user // and treat them as binary changes. See D327 for discussion of why Arcanist // has this behavior. if ($utf8_problems) { $utf8_warning = pht( "This diff includes file(s) which are not valid UTF-8 (they contain ". "invalid byte sequences). You can either stop this workflow and ". "fix these files, or continue. If you continue, these files will ". "be marked as binary.", count($utf8_problems))."\n\n". "You can learn more about how Phabricator handles character encodings ". "(and how to configure encoding settings and detect and correct ". "encoding problems) by reading 'User Guide: UTF-8 and Character ". "Encoding' in the Phabricator documentation.\n\n"; " ".pht('AFFECTED FILE(S)', count($utf8_problems))."\n"; $confirm = pht( 'Do you want to mark these files as binary and continue?', count($utf8_problems)); echo phutil_console_format("**Invalid Content Encoding (Non-UTF8)**\n"); echo phutil_console_wrap($utf8_warning); $file_list = mpull($utf8_problems, 'getCurrentPath'); $file_list = ' '.implode("\n ", $file_list); echo $file_list; if (!phutil_console_confirm($confirm, $default_no = false)) { throw new ArcanistUsageException("Aborted workflow to fix UTF-8."); } else { foreach ($utf8_problems as $change) { $change->convertToBinaryChange(); } } } - foreach ($changes as $change) { - $path = $change->getCurrentPath(); - - // Certain types of changes (moves and copies) don't contain change data - // when expressed in raw "git diff" form. Augment any such diffs with - // textual data. - if ($change->getNeedsSyntheticGitHunks()) { - $diff = $repository_api->getRawDiffText($path, $moves = false); - $parser = $this->newDiffParser(); - - $raw_changes = $parser->parseDiff($diff); - foreach ($raw_changes as $raw_change) { - if ($raw_change->getCurrentPath() == $path) { - $change->setFileType($raw_change->getFileType()); - foreach ($raw_change->getHunks() as $hunk) { - // Git thinks that this file has been added. But we know that it - // has been moved or copied without a change. - $hunk->setCorpus( - preg_replace('/^\+/m', ' ', $hunk->getCorpus())); - $change->addHunk($hunk); - } - break; - } - } - - $change->setNeedsSyntheticGitHunks(false); - } + $changes = $parser->loadSyntheticData($changes, $repository_api); + foreach ($changes as $change) { if ($change->getFileType() != ArcanistDiffChangeType::FILE_BINARY) { continue; } + $path = $change->getCurrentPath(); + $name = basename($path); - $old_file = $repository_api->getOriginalFileData($path); + $old_file = $change->getOriginalFileData(); $old_dict = $this->uploadFile($old_file, $name, 'old binary'); if ($old_dict['guid']) { $change->setMetadata('old:binary-phid', $old_dict['guid']); } $change->setMetadata('old:file:size', $old_dict['size']); $change->setMetadata('old:file:mime-type', $old_dict['mime']); - $new_file = $repository_api->getCurrentFileData($path); + $new_file = $change->getCurrentFileData(); $new_dict = $this->uploadFile($new_file, $name, 'new binary'); if ($new_dict['guid']) { $change->setMetadata('new:binary-phid', $new_dict['guid']); } $change->setMetadata('new:file:size', $new_dict['size']); $change->setMetadata('new:file:mime-type', $new_dict['mime']); $mime_type = coalesce($new_dict['mime'], $old_dict['mime']); if (preg_match('@^image/@', $mime_type)) { $change->setFileType(ArcanistDiffChangeType::FILE_IMAGE); } } return $changes; } private function uploadFile($data, $name, $desc) { $result = array( 'guid' => null, 'mime' => null, 'size' => null ); if ($this->getArgument('skip-binaries')) { return $result; } $result['size'] = $size = strlen($data); if (!$size) { return $result; } $tmp = new TempFile(); Filesystem::writeFile($tmp, $data); $mime_type = Filesystem::getMimeType($tmp); $result['mime'] = $mime_type; echo "Uploading {$desc} '{$name}' ({$mime_type}, {$size} bytes)...\n"; try { $guid = $this->getConduit()->callMethodSynchronous( 'file.upload', array( 'data_base64' => base64_encode($data), 'name' => $name, )); $result['guid'] = $guid; } catch (Exception $e) { echo "Failed to upload {$desc} '{$name}'.\n"; if (!phutil_console_confirm('Continue?', $default_no = false)) { throw new ArcanistUsageException( 'Aborted due to file upload failure. You can use --skip-binaries '. 'to skip binary uploads.'); } } return $result; } private function getGitParentLogInfo() { $info = array( 'parent' => null, 'base_revision' => null, 'base_path' => null, 'uuid' => null, ); $conduit = $this->getConduit(); $repository_api = $this->getRepositoryAPI(); $parser = $this->newDiffParser(); $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 ($message->getGitSVNUUID()) { $info['uuid'] = $message->getGitSVNUUID(); } if ($info['parent'] && $info['base_revision']) { break; } } catch (ArcanistDifferentialCommitMessageParserException $ex) { // Ignore. } catch (ArcanistUsageException $ex) { // Ignore an invalid Differential Revision field in the parent commit } } 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($futures)->limit(8) 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 shouldAmend() { if ($this->haveUncommittedChanges) { return false; } if ($this->isHistoryImmutable()) { return false; } if ($this->getArgument('no-amend')) { return false; } if ($this->isRawDiffSource()) { return false; } return true; } /* -( Lint and Unit Tests )------------------------------------------------ */ /** * @task lintunit */ private function runLintUnit() { $lint_result = $this->runLint(); $unit_result = $this->runUnit(); return array( 'lintResult' => $lint_result, 'lintExcuse' => $this->lintExcuse, 'unresolvedLint' => $this->unresolvedLint, 'postponedLinters' => $this->postponedLinters, 'unitResult' => $unit_result, 'unitExcuse' => $this->unitExcuse, 'testResults' => $this->testResults, ); } /** * @task lintunit */ private function runLint() { if ($this->getArgument('nolint') || $this->getArgument('only') || $this->isRawDiffSource()) { return ArcanistLintWorkflow::RESULT_SKIP; } $repository_api = $this->getRepositoryAPI(); $this->console->writeOut("Linting...\n"); try { $argv = $this->getPassthruArgumentsAsArgv('lint'); if ($repository_api->supportsRelativeLocalCommits()) { $argv[] = '--rev'; $argv[] = $repository_api->getRelativeCommit(); } $lint_workflow = $this->buildChildWorkflow('lint', $argv); if ($this->shouldAmend()) { // TODO: We should offer to create a checkpoint commit. $lint_workflow->setShouldAmendChanges(true); } $lint_result = $lint_workflow->run(); switch ($lint_result) { case ArcanistLintWorkflow::RESULT_OKAY: $this->console->writeOut( "** LINT OKAY ** No lint problems.\n"); break; case ArcanistLintWorkflow::RESULT_WARNINGS: $this->lintExcuse = $this->getErrorExcuse( "Lint issued unresolved warnings.", 'lint-excuses'); break; case ArcanistLintWorkflow::RESULT_ERRORS: $this->console->writeOut( "** LINT ERRORS ** Lint raised errors!\n"); $this->lintExcuse = $this->getErrorExcuse( "Lint issued unresolved errors!", 'lint-excuses'); break; case ArcanistLintWorkflow::RESULT_POSTPONED: $this->console->writeOut( "** LINT POSTPONED ** ". "Lint results are postponed.\n"); break; } $this->unresolvedLint = array(); foreach ($lint_workflow->getUnresolvedMessages() as $message) { $this->unresolvedLint[] = array( 'path' => $message->getPath(), 'line' => $message->getLine(), 'char' => $message->getChar(), 'code' => $message->getCode(), 'severity' => $message->getSeverity(), 'name' => $message->getName(), 'description' => $message->getDescription(), ); } $this->postponedLinters = $lint_workflow->getPostponedLinters(); return $lint_result; } catch (ArcanistNoEngineException $ex) { $this->console->writeOut("No lint engine configured for this project.\n"); } catch (ArcanistNoEffectException $ex) { $this->console->writeOut("No paths to lint.\n"); } return null; } /** * @task lintunit */ private function runUnit() { if ($this->getArgument('nounit') || $this->getArgument('only') || $this->isRawDiffSource()) { return ArcanistUnitWorkflow::RESULT_SKIP; } $repository_api = $this->getRepositoryAPI(); $this->console->writeOut("Running unit tests...\n"); try { $argv = $this->getPassthruArgumentsAsArgv('unit'); if ($repository_api->supportsRelativeLocalCommits()) { $argv[] = '--rev'; $argv[] = $repository_api->getRelativeCommit(); } $unit_workflow = $this->buildChildWorkflow('unit', $argv); $unit_result = $unit_workflow->run(); switch ($unit_result) { case ArcanistUnitWorkflow::RESULT_OKAY: $this->console->writeOut( "** UNIT OKAY ** No unit test failures.\n"); break; case ArcanistUnitWorkflow::RESULT_UNSOUND: if ($this->getArgument('ignore-unsound-tests')) { echo phutil_console_format( "** UNIT UNSOUND ** Unit testing raised errors, ". "but all failing tests are unsound.\n"); } else { $continue = $this->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: $this->console->writeOut( "** UNIT ERRORS ** Unit testing raised errors!\n"); $this->unitExcuse = $this->getErrorExcuse( "Unit test results include failures!", 'unit-excuses'); break; } $this->testResults = array(); foreach ($unit_workflow->getTestResults() as $test) { $this->testResults[] = array( 'name' => $test->getName(), 'link' => $test->getLink(), 'result' => $test->getResult(), 'userdata' => $test->getUserData(), 'coverage' => $test->getCoverage(), 'extra' => $test->getExtraData(), ); } return $unit_result; } catch (ArcanistNoEngineException $ex) { $this->console->writeOut( "No unit test engine is configured for this project.\n"); } catch (ArcanistNoEffectException $ex) { $this->console->writeOut("No tests to run.\n"); } return null; } public function getTestResults() { return $this->testResults; } private function getErrorExcuse($prompt, $history) { if ($this->getArgument('excuse')) { $prompt .= " Ignore them?"; if (!$this->console->confirm($prompt)) { throw new ArcanistUserAbortException(); } return $this->getArgument('excuse'); } $history = $this->getRepositoryAPI()->getScratchFilePath($history); $prompt .= " Provide explanation to continue or press Enter to abort."; $this->console->writeOut("\n\n%s", phutil_console_wrap($prompt)); $return = $this->console->prompt("Explanation:", $history); if ($return == '') { throw new ArcanistUserAbortException(); } return $return; } /* -( Commit and Update Messages )----------------------------------------- */ /** * @task message */ private function buildCommitMessage() { if ($this->getArgument('preview') || $this->getArgument('only')) { return null; } $is_create = $this->getArgument('create'); $is_update = $this->getArgument('update'); $is_raw = $this->isRawDiffSource(); $is_message = $this->getArgument('use-commit-message'); $is_verbatim = $this->getArgument('verbatim'); if ($is_message) { return $this->getCommitMessageFromCommit($is_message); } if ($is_verbatim) { return $this->getCommitMessageFromUser(); } if (!$is_raw && !$is_create && !$is_update) { $repository_api = $this->getRepositoryAPI(); $revisions = $repository_api->loadWorkingCopyDifferentialRevisions( $this->getConduit(), array( 'authors' => array($this->getUserPHID()), 'status' => 'status-open', )); if (!$revisions) { $is_create = true; } else if (count($revisions) == 1) { $revision = head($revisions); $is_update = $revision['id']; } else { throw new ArcanistUsageException( "There are several revisions which match the working copy:\n\n". $this->renderRevisionList($revisions)."\n". "Use '--update' to choose one, or '--create' to create a new ". "revision."); } } $message = null; if ($is_create) { $message_file = $this->getArgument('message-file'); if ($message_file) { return $this->getCommitMessageFromFile($message_file); } else { return $this->getCommitMessageFromUser(); } } else if ($is_update) { $revision_id = $this->normalizeRevisionID($is_update); if (!is_numeric($revision_id)) { throw new ArcanistUsageException( 'Parameter to --update must be a Differential Revision number'); } return $this->getCommitMessageFromRevision($revision_id); } else { // This is --raw without enough info to create a revision, so force just // a diff. return null; } } /** * @task message */ private function getCommitMessageFromCommit($commit) { $text = $this->getRepositoryAPI()->getCommitMessage($commit); $message = ArcanistDifferentialCommitMessage::newFromRawCorpus($text); $message->pullDataFromConduit($this->getConduit()); $this->validateCommitMessage($message); return $message; } /** * @task message */ private function getCommitMessageFromUser() { $conduit = $this->getConduit(); $template = null; if (!$this->getArgument('verbatim')) { $saved = $this->readScratchFile('create-message'); if ($saved) { $where = $this->getReadableScratchFilePath('create-message'); $preview = explode("\n", $saved); $preview = array_shift($preview); $preview = trim($preview); $preview = phutil_utf8_shorten($preview, 64); if ($preview) { $preview = "Message begins:\n\n {$preview}\n\n"; } else { $preview = null; } echo "You have a saved revision message in '{$where}'.\n". "{$preview}". "You can use this message, or discard it."; $use = phutil_console_confirm( "Do you want to use this message?", $default_no = false); if ($use) { $template = $saved; } else { $this->removeScratchFile('create-message'); } } } $template_is_default = false; $notes = array(); $included = array(); list($fields, $notes, $included_commits) = $this->getDefaultCreateFields(); if ($template) { $fields = array(); $notes = array(); } else { if (!$fields) { $template_is_default = true; } if ($notes) { $commit = head($this->getRepositoryAPI()->getLocalCommitInformation()); $template = $commit['message']; } else { $template = $conduit->callMethodSynchronous( 'differential.getcommitmessage', array( 'revision_id' => null, 'edit' => 'create', 'fields' => $fields, )); } } $included = array(); if ($included_commits) { foreach ($included_commits as $commit) { $included[] = ' '.$commit; } $in_branch = ''; if (!$this->isRawDiffSource()) { $in_branch = ' in branch '.$this->getRepositoryAPI()->getBranchName(); } $included = array_merge( array( "", "Included commits{$in_branch}:", "", ), $included); } $issues = array_merge( array( 'NEW DIFFERENTIAL REVISION', 'Describe the changes in this new revision.', ), $included, array( '', 'arc could not identify any existing revision in your working copy.', 'If you intended to update an existing revision, use:', '', ' $ arc diff --update ', )); if ($notes) { $issues = array_merge($issues, array(''), $notes); } $done = false; $first = true; while (!$done) { $template = rtrim($template, "\r\n")."\n\n"; foreach ($issues as $issue) { $template .= '# '.$issue."\n"; } $template .= "\n"; if ($first && $this->getArgument('verbatim') && !$template_is_default) { $new_template = $template; } else { $new_template = $this->newInteractiveEditor($template) ->setName('new-commit') ->editInteractively(); } $first = false; if ($template_is_default && ($new_template == $template)) { throw new ArcanistUsageException("Template not edited."); } $template = ArcanistCommentRemover::removeComments($new_template); $repository_api = $this->getRepositoryAPI(); $should_amend = (count($included_commits) == 1 && $this->shouldAmend()); if ($should_amend && $repository_api->supportsAmend()) { $repository_api->amendCommit($template); $wrote = true; $where = 'commit message'; } else { $wrote = $this->writeScratchFile('create-message', $template); $where = "'".$this->getReadableScratchFilePath('create-message')."'"; } try { $message = ArcanistDifferentialCommitMessage::newFromRawCorpus( $template); $message->pullDataFromConduit($conduit); $this->validateCommitMessage($message); $done = true; } catch (ArcanistDifferentialCommitMessageParserException $ex) { echo "Commit message has errors:\n\n"; $issues = array('Resolve these errors:'); foreach ($ex->getParserErrors() as $error) { echo phutil_console_wrap("- ".$error."\n", 6); $issues[] = ' - '.$error; } echo "\n"; echo "You must resolve these errors to continue."; $again = phutil_console_confirm( "Do you want to edit the message?", $default_no = false); if ($again) { // Keep going. } else { $saved = null; if ($wrote) { $saved = "A copy was saved to {$where}."; } throw new ArcanistUsageException( "Message has unresolved errrors. {$saved}"); } } catch (Exception $ex) { if ($wrote) { echo phutil_console_wrap("(Message saved to {$where}.)\n"); } throw $ex; } } return $message; } /** * @task message */ private function getCommitMessageFromFile($file) { $conduit = $this->getConduit(); $data = Filesystem::readFile($file); $message = ArcanistDifferentialCommitMessage::newFromRawCorpus($data); $message->pullDataFromConduit($conduit); $this->validateCommitMessage($message); return $message; } /** * @task message */ private function getCommitMessageFromRevision($revision_id) { $id = $revision_id; $revision = $this->getConduit()->callMethodSynchronous( 'differential.query', array( 'ids' => array($id), )); $revision = head($revision); if (!$revision) { throw new ArcanistUsageException( "Revision '{$revision_id}' does not exist!"); } if ($revision['authorPHID'] != $this->getUserPHID()) { $rev_title = $revision['title']; throw new ArcanistUsageException( "You don't own revision D{$id} '{$rev_title}'. You can only update ". "revisions you own."); } $message = $this->getConduit()->callMethodSynchronous( 'differential.getcommitmessage', array( 'revision_id' => $id, 'edit' => false, )); $this->commitMessageFromRevision = $message; $obj = ArcanistDifferentialCommitMessage::newFromRawCorpus($message); $obj->pullDataFromConduit($this->getConduit()); return $obj; } /** * @task message */ private function validateCommitMessage( ArcanistDifferentialCommitMessage $message) { $reviewers = $message->getFieldValue('reviewerPHIDs'); if (!$reviewers) { $confirm = "You have not specified any reviewers. Continue anyway?"; if (!phutil_console_confirm($confirm)) { throw new ArcanistUsageException('Specify reviewers and retry.'); } } else { $users = $this->getConduit()->callMethodSynchronous( 'user.query', array( 'phids' => $reviewers, )); $untils = array(); foreach ($users as $user) { if (idx($user, 'currentStatus') == 'away') { $untils[] = $user['currentStatusUntil']; } } if (count($untils) == count($reviewers)) { $until = date('l, M j Y', min($untils)); $confirm = "All reviewers are away until {$until}. Continue anyway?"; if (!phutil_console_confirm($confirm)) { throw new ArcanistUsageException( 'Specify available reviewers and retry.'); } } } } /** * @task message */ private function getUpdateMessage(array $fields, $template = '') { if ($this->getArgument('raw')) { throw new ArcanistUsageException( "When using '--raw' to update a revision, specify an update message ". "with '--message'. (Normally, we'd launch an editor to ask you for a ". "message, but can not do that because stdin is the diff source.)"); } // 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. Similar things apply // to Mercurial. if ($template == '') { $comments = $this->getDefaultUpdateMessage(); $template = rtrim($comments). "\n\n". "# Updating D{$fields['revisionID']}: {$fields['title']}\n". "#\n". "# Enter a brief description of the changes included in this update.\n". "# The first line is used as subject, next lines as comment.\n". "#\n". "# If you intended to create a new revision, use:\n". "# $ arc diff --create\n". "\n"; } $comments = $this->newInteractiveEditor($template) ->setName('differential-update-comments') ->editInteractively(); return $comments; } private function getDefaultCreateFields() { $result = array(array(), array(), array()); if ($this->isRawDiffSource()) { return $result; } $repository_api = $this->getRepositoryAPI(); $local = $repository_api->getLocalCommitInformation(); if ($local) { $result = $this->parseCommitMessagesIntoFields($local); } $result[0] = $this->dispatchWillBuildEvent($result[0]); return $result; } /** * Convert a list of commits from `getLocalCommitInformation()` into * a format usable by arc to create a new diff. Specifically, we emit: * * - A dictionary of commit message fields. * - A list of errors encountered while parsing the messages. * - A human-readable list of the commits themselves. * * For example, if the user runs "arc diff HEAD^^^" and selects a diff range * which includes several diffs, we attempt to merge them somewhat * intelligently into a single message, because we can only send one * "Summary:", "Reviewers:", etc., field to Differential. We also return * errors (e.g., if the user typed a reviewer name incorrectly) and a * summary of the commits themselves. * * @param dict Local commit information. * @return list Complex output, see summary. * @task message */ private function parseCommitMessagesIntoFields(array $local) { $conduit = $this->getConduit(); $local = ipull($local, null, 'commit'); // If the user provided "--reviewers" or "--ccs", add a faux message to // the list with the implied fields. $faux_message = array(); if ($this->getArgument('reviewers')) { $faux_message[] = 'Reviewers: '.$this->getArgument('reviewers'); } if ($this->getArgument('cc')) { $faux_message[] = 'CC: '.$this->getArgument('cc'); } if ($faux_message) { $faux_message = implode("\n\n", $faux_message); $local = array( '(Flags) ' => array( 'message' => $faux_message, 'summary' => 'Command-Line Flags', ), ) + $local; } // Build a human-readable list of the commits, so we can show the user which // commits are included in the diff. $included = array(); foreach ($local as $hash => $info) { $included[] = substr($hash, 0, 12).' '.$info['summary']; } // Parse all of the messages into fields. $messages = array(); foreach ($local as $hash => $info) { $text = $info['message']; $obj = ArcanistDifferentialCommitMessage::newFromRawCorpus($text); $messages[$hash] = $obj; } $notes = array(); $fields = array(); foreach ($messages as $hash => $message) { try { $message->pullDataFromConduit($conduit, $partial = true); $fields[$hash] = $message->getFields(); } catch (ArcanistDifferentialCommitMessageParserException $ex) { if ($this->getArgument('verbatim')) { // In verbatim mode, just bail when we hit an error. The user can // rerun without --verbatim if they want to fix it manually. Most // users will probably `git commit --amend` instead. throw $ex; } $fields[$hash] = $message->getFields(); $frev = substr($hash, 0, 12); $notes[] = "NOTE: commit {$frev} could not be completely parsed:"; foreach ($ex->getParserErrors() as $error) { $notes[] = " - {$error}"; } } } // Merge commit message fields. We do this somewhat-intelligently so that // multiple "Reviewers" or "CC" fields will merge into the concatenation // of all values. // We have special parsing rules for 'title' because we can't merge // multiple titles, and one-line commit messages like "fix stuff" will // parse as titles. Instead, pick the first title we encounter. When we // encounter subsequent titles, treat them as part of the summary. Then // we merge all the summaries together below. $result = array(); // Process fields in oldest-first order, so earlier commits get to set the // title of record and reviewers/ccs are listed in chronological order. $fields = array_reverse($fields); foreach ($fields as $hash => $dict) { $title = idx($dict, 'title'); if (!strlen($title)) { continue; } if (!isset($result['title'])) { // We don't have a title yet, so use this one. $result['title'] = $title; } else { // We already have a title, so merge this new title into the summary. $summary = idx($dict, 'summary'); if ($summary) { $summary = $title."\n\n".$summary; } else { $summary = $title; } $fields[$hash]['summary'] = $summary; } } // Now, merge all the other fields in a general sort of way. foreach ($fields as $hash => $dict) { foreach ($dict as $key => $value) { if ($key == 'title') { // This has been handled above, and either assigned directly or // merged into the summary. continue; } if (is_array($value)) { // For array values, merge the arrays, appending the new values. // Examples are "Reviewers" and "Cc", where this produces a list of // all users specified as reviewers. $cur = idx($result, $key, array()); $new = array_merge($cur, $value); $result[$key] = $new; continue; } else { if (!strlen(trim($value))) { // Ignore empty fields. continue; } // For string values, append the new field to the old field with // a blank line separating them. Examples are "Test Plan" and // "Summary". $cur = idx($result, $key, ''); if (strlen($cur)) { $new = $cur."\n\n".$value; } else { $new = $value; } $result[$key] = $new; } } } return array($result, $notes, $included); } private function getDefaultUpdateMessage() { if ($this->isRawDiffSource()) { return null; } $repository_api = $this->getRepositoryAPI(); if ($repository_api instanceof ArcanistGitAPI) { return $this->getGitUpdateMessage(); } if ($repository_api instanceof ArcanistMercurialAPI) { return $this->getMercurialUpdateMessage(); } return null; } /** * Retrieve the git messages between HEAD and the last update. * * @task message */ private function getGitUpdateMessage() { $repository_api = $this->getRepositoryAPI(); $parser = $this->newDiffParser(); $commit_messages = $repository_api->getGitCommitLog(); $commit_messages = $parser->parseDiff($commit_messages); if (count($commit_messages) == 1) { // If there's only one message, assume this is an amend-based workflow and // that using it to prefill doesn't make sense. return null; } // We have more than one message, so figure out which ones are new. We // do this by pulling the current diff and comparing commit hashes in the // working copy with attached commit hashes. It's not super important that // we always get this 100% right, we're just trying to do something // reasonable. $local = $this->loadActiveLocalCommitInfo(); $hashes = ipull($local, null, 'commit'); $usable = array(); foreach ($commit_messages as $message) { $text = $message->getMetadata('message'); $parsed = ArcanistDifferentialCommitMessage::newFromRawCorpus($text); if ($parsed->getRevisionID()) { // If this is an amended commit message with a revision ID, it's // certainly not new. Stop marking commits as usable and break out. break; } if (isset($hashes[$message->getCommitHash()])) { // If this commit is currently part of the diff, stop using commit // messages, since anything older than this isn't new. break; } // Otherwise, this looks new, so it's a usable commit message. $usable[] = $text; } if (!$usable) { // No new commit messages, so we don't have anywhere to start from. return null; } return $this->formatUsableLogs($usable); } /** * Retrieve the hg messages between tip and the last update. * * @task message */ private function getMercurialUpdateMessage() { $repository_api = $this->getRepositoryAPI(); $messages = $repository_api->getCommitMessageLog(); $local = $this->loadActiveLocalCommitInfo(); $hashes = ipull($local, null, 'commit'); $usable = array(); foreach ($messages as $rev => $message) { if (isset($hashes[$rev])) { // If this commit is currently part of the active diff on the revision, // stop using commit messages, since anything older than this isn't new. break; } // Otherwise, this looks new, so it's a usable commit message. $usable[] = $message; } if (!$usable) { // No new commit messages, so we don't have anywhere to start from. return null; } return $this->formatUsableLogs($usable); } /** * Format log messages to prefill a diff update. * * @task message */ private function formatUsableLogs(array $usable) { // Flip messages so they'll read chronologically (oldest-first) in the // template, e.g.: // // - Added foobar. // - Fixed foobar bug. // - Documented foobar. $usable = array_reverse($usable); $default = array(); foreach ($usable as $message) { // Pick the first line out of each message. $text = trim($message); $text = head(explode("\n", $text)); $default[] = ' - '.$text."\n"; } return implode('', $default); } private function loadActiveLocalCommitInfo() { $current_diff = $this->getConduit()->callMethodSynchronous( 'differential.getdiff', array( 'revision_id' => $this->revisionID, )); $properties = idx($current_diff, 'properties', array()); return idx($properties, 'local:commits', array()); } /* -( Diff Specification )------------------------------------------------- */ /** * @task diffspec */ private function getLintStatus($lint_result) { $map = array( ArcanistLintWorkflow::RESULT_OKAY => 'okay', ArcanistLintWorkflow::RESULT_ERRORS => 'fail', ArcanistLintWorkflow::RESULT_WARNINGS => 'warn', ArcanistLintWorkflow::RESULT_SKIP => 'skip', ArcanistLintWorkflow::RESULT_POSTPONED => 'postponed', ); return idx($map, $lint_result, 'none'); } /** * @task diffspec */ private function getUnitStatus($unit_result) { $map = array( ArcanistUnitWorkflow::RESULT_OKAY => 'okay', ArcanistUnitWorkflow::RESULT_FAIL => 'fail', ArcanistUnitWorkflow::RESULT_UNSOUND => 'warn', ArcanistUnitWorkflow::RESULT_SKIP => 'skip', ArcanistUnitWorkflow::RESULT_POSTPONED => 'postponed', ); return idx($map, $unit_result, 'none'); } /** * @task diffspec */ private function buildDiffSpecification() { $base_revision = null; $base_path = null; $vcs = null; $repo_uuid = null; $parent = null; $source_path = null; $branch = null; $bookmark = null; if (!$this->isRawDiffSource()) { $repository_api = $this->getRepositoryAPI(); $base_revision = $repository_api->getSourceControlBaseRevision(); $base_path = $repository_api->getSourceControlPath(); $vcs = $repository_api->getSourceControlSystemName(); $source_path = $repository_api->getPath(); $branch = $repository_api->getBranchName(); 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']; } if ($info['uuid']) { $repo_uuid = $info['uuid']; } } else if ($repository_api instanceof ArcanistSubversionAPI) { $repo_uuid = $repository_api->getRepositorySVNUUID(); } else if ($repository_api instanceof ArcanistMercurialAPI) { $bookmark = $repository_api->getActiveBookmark(); $svn_info = $repository_api->getSubversionInfo(); $repo_uuid = idx($svn_info, 'uuid'); $base_path = idx($svn_info, 'base_path', $base_path); $base_revision = idx($svn_info, 'base_revision', $base_revision); // TODO: provide parent info } else { throw new Exception("Unsupported repository API!"); } } $project_id = null; if ($this->requiresWorkingCopy()) { $project_id = $this->getWorkingCopy()->getProjectID(); } return array( 'sourceMachine' => php_uname('n'), 'sourcePath' => $source_path, 'branch' => $branch, 'bookmark' => $bookmark, 'sourceControlSystem' => $vcs, 'sourceControlPath' => $base_path, 'sourceControlBaseRevision' => $base_revision, 'parentRevisionID' => $parent, 'repositoryUUID' => $repo_uuid, 'creationMethod' => 'arc', 'arcanistProject' => $project_id, 'authorPHID' => $this->getUserPHID(), ); } /* -( Diff Properties )---------------------------------------------------- */ /** * Update lint information for the diff. * * @return void * * @task diffprop */ private function updateLintDiffProperty() { if ($this->unresolvedLint) { $this->updateDiffProperty('arc:lint', json_encode($this->unresolvedLint)); if (strlen($this->lintExcuse)) { $this->updateDiffProperty('arc:lint-excuse', json_encode($this->lintExcuse)); } } $postponed = $this->postponedLinters; if ($postponed) { $this->updateDiffProperty('arc:lint-postponed', json_encode($postponed)); } } /** * Update unit test information for the diff. * * @return void * * @task diffprop */ private function updateUnitDiffProperty() { if (!$this->testResults) { return; } $this->updateDiffProperty('arc:unit', json_encode($this->testResults)); if (strlen($this->unitExcuse)) { $this->updateDiffProperty('arc:unit-excuse', json_encode($this->unitExcuse)); } } /** * Update local commit information for the diff. * * @task diffprop */ private function updateLocalDiffProperty() { if ($this->isRawDiffSource()) { return; } $local_info = $this->getRepositoryAPI()->getLocalCommitInformation(); if (!$local_info) { return; } $this->updateDiffProperty('local:commits', json_encode($local_info)); } /** * Update an arbitrary diff property. * * @param string Diff property name. * @param string Diff property value. * @return void * * @task diffprop */ private function updateDiffProperty($name, $data) { $this->diffPropertyFutures[] = $this->getConduit()->callMethod( 'differential.setdiffproperty', array( 'diff_id' => $this->getDiffID(), 'name' => $name, 'data' => $data, )); } /** * Wait for finishing all diff property updates. * * @return void * * @task diffprop */ private function resolveDiffPropertyUpdates() { Futures($this->diffPropertyFutures)->resolveAll(); $this->diffPropertyFutures = array(); } private function dispatchWillCreateRevisionEvent(array $fields) { $event = $this->dispatchEvent( ArcanistEventType::TYPE_REVISION_WILLCREATEREVISION, array( 'specification' => $fields, )); return $event->getValue('specification'); } private function dispatchWillBuildEvent(array $fields) { $event = $this->dispatchEvent( ArcanistEventType::TYPE_DIFF_WILLBUILDMESSAGE, array( 'fields' => $fields, )); return $event->getValue('fields'); } } diff --git a/src/workflow/ArcanistExportWorkflow.php b/src/workflow/ArcanistExportWorkflow.php index 6b92f25c..51003708 100644 --- a/src/workflow/ArcanistExportWorkflow.php +++ b/src/workflow/ArcanistExportWorkflow.php @@ -1,262 +1,264 @@ array( 'help' => "Export change as a git patch. This format is more complete than ". "unified, but less complete than arc bundles. These patches can be ". "applied with 'git apply' or 'arc patch'.", ), 'unified' => array( 'help' => "Export change as a unified patch. This format is less complete ". "than git patches or arc bundles. These patches can be applied with ". "'patch' or 'arc patch'.", ), 'arcbundle' => array( 'param' => 'file', 'help' => "Export change as an arc bundle. This format can represent all ". "changes. These bundles can be applied with 'arc patch'.", ), 'encoding' => array( 'param' => 'encoding', 'help' => "Attempt to convert non UTF-8 patch into specified encoding.", ), 'revision' => array( 'param' => 'revision_id', 'help' => "Instead of exporting changes from the working copy, export them ". "from a Differential revision." ), 'diff' => array( 'param' => 'diff_id', 'help' => "Instead of exporting changes from the working copy, export them ". "from a Differential diff." ), '*' => 'paths', ); } protected function didParseArguments() { $source = self::SOURCE_LOCAL; $requested = 0; if ($this->getArgument('revision')) { $source = self::SOURCE_REVISION; $requested++; $source_id = $this->getArgument($source); $this->sourceID = $this->normalizeRevisionID($source_id); } if ($this->getArgument('diff')) { $source = self::SOURCE_DIFF; $requested++; $this->sourceID = $this->getArgument($source); } $this->source = $source; if ($requested > 1) { throw new ArcanistUsageException( "Options '--revision' and '--diff' are not compatible. Choose exactly ". "one change source."); } $format = null; $requested = 0; if ($this->getArgument('git')) { $format = self::FORMAT_GIT; $requested++; } if ($this->getArgument('unified')) { $format = self::FORMAT_UNIFIED; $requested++; } if ($this->getArgument('arcbundle')) { $format = self::FORMAT_BUNDLE; $requested++; } if ($requested === 0) { throw new ArcanistUsageException( "Specify one of '--git', '--unified' or '--arcbundle ' to ". "choose an export format."); } else if ($requested > 1) { throw new ArcanistUsageException( "Options '--git', '--unified' and '--arcbundle' are not compatible. ". "Choose exactly one export format."); } $this->format = $format; } public function requiresConduit() { return true; } public function requiresAuthentication() { return $this->requiresConduit(); } public function requiresRepositoryAPI() { return $this->getSource() == self::SOURCE_LOCAL; } public function requiresWorkingCopy() { return $this->getSource() == self::SOURCE_LOCAL; } private function getSource() { return $this->source; } private function getSourceID() { return $this->sourceID; } private function getFormat() { return $this->format; } public function run() { $source = $this->getSource(); switch ($source) { case self::SOURCE_LOCAL: $repository_api = $this->getRepositoryAPI(); $parser = new ArcanistDiffParser(); if ($repository_api instanceof ArcanistGitAPI) { $repository_api->parseRelativeLocalCommit( $this->getArgument('paths')); $diff = $repository_api->getFullGitDiff(); $changes = $parser->parseDiff($diff); } else { // TODO: paths support $paths = $repository_api->getWorkingCopyStatus(); $changes = $parser->parseSubversionDiff( $repository_api, $paths); } + $changes = $parser->loadSyntheticData($changes, $repository_api); + $bundle = ArcanistBundle::newFromChanges($changes); $bundle->setProjectID($this->getWorkingCopy()->getProjectID()); $bundle->setBaseRevision( $repository_api->getSourceControlBaseRevision()); // note we can't get a revision ID for SOURCE_LOCAL break; case self::SOURCE_REVISION: $bundle = $this->loadRevisionBundleFromConduit( $this->getConduit(), $this->getSourceID()); break; case self::SOURCE_DIFF: $bundle = $this->loadDiffBundleFromConduit( $this->getConduit(), $this->getSourceID()); break; } $try_encoding = nonempty($this->getArgument('encoding'), null); if (!$try_encoding) { try { $project_info = $this->getConduit()->callMethodSynchronous( 'arcanist.projectinfo', array( 'name' => $bundle->getProjectID(), )); $try_encoding = $project_info['encoding']; } catch (ConduitClientException $e) { $try_encoding = null; } } if ($try_encoding) { $bundle->setEncoding($try_encoding); } $format = $this->getFormat(); switch ($format) { case self::FORMAT_GIT: echo $bundle->toGitPatch(); break; case self::FORMAT_UNIFIED: echo $bundle->toUnifiedDiff(); break; case self::FORMAT_BUNDLE: $path = $this->getArgument('arcbundle'); echo "Writing bundle to '{$path}'...\n"; $bundle->writeToDisk($path); echo "done.\n"; break; } return 0; } } diff --git a/src/workflow/ArcanistPatchWorkflow.php b/src/workflow/ArcanistPatchWorkflow.php index efd0b251..6d2e2dfd 100644 --- a/src/workflow/ArcanistPatchWorkflow.php +++ b/src/workflow/ArcanistPatchWorkflow.php @@ -1,988 +1,992 @@ array( 'param' => 'revision_id', 'paramtype' => 'complete', 'help' => "Apply changes from a Differential revision, using the most recent ". "diff that has been attached to it. You can run 'arc patch D12345' ". "as a shorthand.", ), 'diff' => array( 'param' => 'diff_id', 'help' => "Apply changes from a Differential diff. Normally you want to use ". "--revision to get the most recent changes, but you can ". "specifically apply an out-of-date diff or a diff which was never ". "attached to a revision by using this flag.", ), 'arcbundle' => array( 'param' => 'bundlefile', 'paramtype' => 'file', 'help' => "Apply changes from an arc bundle generated with 'arc export'.", ), 'patch' => array( 'param' => 'patchfile', 'paramtype' => 'file', 'help' => "Apply changes from a git patchfile or unified patchfile.", ), 'encoding' => array( 'param' => 'encoding', 'help' => "Attempt to convert non UTF-8 patch into specified encoding.", ), 'update' => array( 'supports' => array( 'git', 'svn', 'hg' ), 'help' => "Update the local working copy before applying the patch.", 'conflicts' => array( 'nobranch' => true, 'bookmark' => true, ), ), 'nocommit' => array( 'supports' => array( 'git', 'hg' ), 'help' => "Normally under git/hg, if the patch is successful, the changes ". "are committed to the working copy. This flag prevents the commit.", ), 'nobranch' => array( 'supports' => array( 'git' ), 'help' => "Normally under git, a new branch is created and then the patch ". "is applied and committed in the new branch. This flag ". "cherry-picks the resultant commit onto the original branch and ". "deletes the temporary branch.", 'conflicts' => array( 'update' => true, ), ), 'bookmark' => array( 'supports' => array( 'hg' ), 'help' => "Normally under hg, a new bookmark is not created and the patch ". "is applied and committed in the current bookmark. With this flag, ". "a new bookmark is created and the patch is applied and committed ". "in the new bookmark.", 'conflicts' => array( 'update' => true, ), ), 'force' => array( 'help' => "Do not run any sanity checks.", ), '*' => 'name', ); } protected function didParseArguments() { $source = null; $requested = 0; if ($this->getArgument('revision')) { $source = self::SOURCE_REVISION; $requested++; } if ($this->getArgument('diff')) { $source = self::SOURCE_DIFF; $requested++; } if ($this->getArgument('arcbundle')) { $source = self::SOURCE_BUNDLE; $requested++; } if ($this->getArgument('patch')) { $source = self::SOURCE_PATCH; $requested++; } $use_revision_id = null; if ($this->getArgument('name')) { $namev = $this->getArgument('name'); if (count($namev) > 1) { throw new ArcanistUsageException("Specify at most one revision name."); } $source = self::SOURCE_REVISION; $requested++; $use_revision_id = $this->normalizeRevisionID(head($namev)); } if ($requested === 0) { throw new ArcanistUsageException( "Specify one of 'D12345', '--revision ' (to select the ". "current changes attached to a Differential revision), ". "'--diff ' (to select a specific, out-of-date diff or a ". "diff which is not attached to a revision), '--arcbundle ' ". "or '--patch ' to choose a patch source."); } else if ($requested > 1) { throw new ArcanistUsageException( "Options 'D12345', '--revision', '--diff', '--arcbundle' and ". "'--patch' are not compatible. Choose exactly one patch source."); } $this->source = $source; $this->sourceParam = nonempty( $use_revision_id, $this->getArgument($source)); } public function requiresConduit() { return ($this->getSource() != self::SOURCE_PATCH); } public function requiresRepositoryAPI() { return true; } public function requiresWorkingCopy() { return true; } private function getSource() { return $this->source; } private function getSourceParam() { return $this->sourceParam; } private function shouldCommit() { $no_commit = $this->getArgument('nocommit', false); if ($no_commit) { return false; } return true; } private function canBranch() { // git only for now $repository_api = $this->getRepositoryAPI(); if (!($repository_api instanceof ArcanistGitAPI)) { return false; } return true; } private function shouldBranch() { $no_branch = $this->getArgument('nobranch', false); if ($no_branch) { return false; } return true; } private function shouldBookmark() { // specific to hg $repository_api = $this->getRepositoryAPI(); if (!($repository_api instanceof ArcanistMercurialAPI)) { return false; } $bookmark = $this->getArgument('bookmark', false); if ($bookmark) { return true; } return false; } private function getBranchName(ArcanistBundle $bundle) { $branch_name = null; $repository_api = $this->getRepositoryAPI(); $revision_id = $bundle->getRevisionID(); $base_name = "arcpatch"; if ($revision_id) { $base_name .= "-D{$revision_id}"; } $suffixes = array(null, '-1', '-2', '-3'); foreach ($suffixes as $suffix) { $proposed_name = $base_name.$suffix; list($err) = $repository_api->execManualLocal( 'rev-parse --verify %s', $proposed_name); // no error means git rev-parse found a branch if (!$err) { echo phutil_console_format( "Branch name {$proposed_name} already exists; trying a new name.\n" ); continue; } else { $branch_name = $proposed_name; break; } } if (!$branch_name) { throw new Exception( "Arc was unable to automagically make a name for this patch. ". "Please clean up your working copy and try again." ); } return $branch_name; } private function getBookmarkName(ArcanistBundle $bundle) { $bookmark_name = null; $repository_api = $this->getRepositoryAPI(); $revision_id = $bundle->getRevisionID(); $base_name = "arcpatch"; if ($revision_id) { $base_name .= "-D{$revision_id}"; } $suffixes = array(null, '-1', '-2', '-3'); foreach ($suffixes as $suffix) { $proposed_name = $base_name.$suffix; list($err) = $repository_api->execManualLocal( 'log -r %s', $proposed_name); // no error means hg log found a bookmark if (!$err) { echo phutil_console_format( "Bookmark name {$proposed_name} already exists; trying a new name.\n" ); continue; } else { $bookmark_name = $proposed_name; break; } } if (!$bookmark_name) { throw new Exception( "Arc was unable to automagically make a name for this patch. ". "Please clean up your working copy and try again." ); } return $bookmark_name; } private function hasBaseRevision(ArcanistBundle $bundle) { $base_revision = $bundle->getBaseRevision(); $repository_api = $this->getRepositoryAPI(); // verify the base revision is valid // in a working copy that uses the git-svn bridge, the base revision might // be a svn uri instead of a git ref // NOTE: Use 'cat-file', not 'rev-parse --verify', because 'rev-parse' // always "verifies" any properly-formatted commit even if it does not // exist. list($err) = $repository_api->execManualLocal( 'cat-file -t %s', $base_revision); return !$err; } private function createBranch(ArcanistBundle $bundle, $has_base_revision) { $branch_name = $this->getBranchName($bundle); $base_revision = $bundle->getBaseRevision(); $repository_api = $this->getRepositoryAPI(); if ($base_revision && $has_base_revision) { $repository_api->execxLocal( 'checkout -b %s %s', $branch_name, $base_revision); } else { $repository_api->execxLocal( 'checkout -b %s', $branch_name); } echo phutil_console_format( "Created and checked out branch %s.\n", $branch_name); return $branch_name; } private function createBookmark(ArcanistBundle $bundle) { $bookmark_name = $this->getBookmarkName($bundle); $repository_api = $this->getRepositoryAPI(); $repository_api->execxLocal( 'bookmark %s', $bookmark_name); echo phutil_console_format( "Created and applied bookmark %s.\n", $bookmark_name); } private function shouldUpdateWorkingCopy() { return $this->getArgument('update', false); } private function updateWorkingCopy() { echo "Updating working copy...\n"; $this->getRepositoryAPI()->updateWorkingCopy(); echo "Done.\n"; } public function run() { $source = $this->getSource(); $param = $this->getSourceParam(); try { switch ($source) { case self::SOURCE_PATCH: if ($param == '-') { $patch = @file_get_contents('php://stdin'); if (!strlen($patch)) { throw new ArcanistUsageException( "Failed to read patch from stdin!"); } } else { $patch = Filesystem::readFile($param); } $bundle = ArcanistBundle::newFromDiff($patch); break; case self::SOURCE_BUNDLE: $path = $this->getArgument('arcbundle'); $bundle = ArcanistBundle::newFromArcBundle($path); break; case self::SOURCE_REVISION: $bundle = $this->loadRevisionBundleFromConduit( $this->getConduit(), $param); break; case self::SOURCE_DIFF: $bundle = $this->loadDiffBundleFromConduit( $this->getConduit(), $param); break; } } catch (ConduitClientException $ex) { if ($ex->getErrorCode() == 'ERR-INVALID-SESSION') { // Phabricator is not configured to allow anonymous access to // Differential. $this->authenticateConduit(); return $this->run(); } else { throw $ex; } } $try_encoding = nonempty($this->getArgument('encoding'), null); if (!$try_encoding) { if ($this->requiresConduit()) { try { $try_encoding = $this->getRepositoryEncoding(); } catch (ConduitClientException $e) { $try_encoding = null; } } } if ($try_encoding) { $bundle->setEncoding($try_encoding); } $force = $this->getArgument('force', false); if ($force) { // force means don't do any sanity checks about the patch } else { $this->sanityCheck($bundle); } // we should update the working copy before we do ANYTHING else if ($this->shouldUpdateWorkingCopy()) { $this->updateWorkingCopy(); } $repository_api = $this->getRepositoryAPI(); $has_base_revision = $this->hasBaseRevision($bundle); if ($this->canBranch() && ($this->shouldBranch() || $has_base_revision)) { $original_branch = $repository_api->getBranchName(); $new_branch = $this->createBranch($bundle, $has_base_revision); } if ($this->shouldBookmark()) { $this->createBookmark($bundle); } if ($repository_api instanceof ArcanistSubversionAPI) { $patch_err = 0; $copies = array(); $deletes = array(); $patches = array(); $propset = array(); $adds = array(); $symlinks = array(); $changes = $bundle->getChanges(); foreach ($changes as $change) { $type = $change->getType(); $should_patch = true; $filetype = $change->getFileType(); switch ($filetype) { case ArcanistDiffChangeType::FILE_SYMLINK: $should_patch = false; $symlinks[] = $change; break; } switch ($type) { case ArcanistDiffChangeType::TYPE_MOVE_AWAY: case ArcanistDiffChangeType::TYPE_MULTICOPY: case ArcanistDiffChangeType::TYPE_DELETE: $path = $change->getCurrentPath(); $fpath = $repository_api->getPath($path); if (!@file_exists($fpath)) { $ok = phutil_console_confirm( "Patch deletes file '{$path}', but the file does not exist in ". "the working copy. Continue anyway?"); if (!$ok) { throw new ArcanistUserAbortException(); } } else { $deletes[] = $change->getCurrentPath(); } $should_patch = false; break; case ArcanistDiffChangeType::TYPE_COPY_HERE: case ArcanistDiffChangeType::TYPE_MOVE_HERE: $path = $change->getOldPath(); $fpath = $repository_api->getPath($path); if (!@file_exists($fpath)) { $cpath = $change->getCurrentPath(); if ($type == ArcanistDiffChangeType::TYPE_COPY_HERE) { $verbs = 'copies'; } else { $verbs = 'moves'; } $ok = phutil_console_confirm( "Patch {$verbs} '{$path}' to '{$cpath}', but source path ". "does not exist in the working copy. Continue anyway?"); if (!$ok) { throw new ArcanistUserAbortException(); } } else { $copies[] = array( $change->getOldPath(), $change->getCurrentPath()); } break; case ArcanistDiffChangeType::TYPE_ADD: $adds[] = $change->getCurrentPath(); break; } if ($should_patch) { $cbundle = ArcanistBundle::newFromChanges(array($change)); $patches[$change->getCurrentPath()] = $cbundle->toUnifiedDiff(); $prop_old = $change->getOldProperties(); $prop_new = $change->getNewProperties(); $props = $prop_old + $prop_new; foreach ($props as $key => $ignored) { if (idx($prop_old, $key) !== idx($prop_new, $key)) { $propset[$change->getCurrentPath()][$key] = idx($prop_new, $key); } } } } // Before we start doing anything, create all the directories we're going // to add files to if they don't already exist. foreach ($copies as $copy) { list($src, $dst) = $copy; $this->createParentDirectoryOf($dst); } foreach ($patches as $path => $patch) { $this->createParentDirectoryOf($path); } foreach ($adds as $add) { $this->createParentDirectoryOf($add); } // TODO: The SVN patch workflow likely does not work on windows because // of the (cd ...) stuff. foreach ($copies as $copy) { list($src, $dst) = $copy; passthru( csprintf( '(cd %s; svn cp %s %s)', $repository_api->getPath(), $src, $dst)); } foreach ($deletes as $delete) { passthru( csprintf( '(cd %s; svn rm %s)', $repository_api->getPath(), $delete)); } foreach ($symlinks as $symlink) { $link_target = $symlink->getSymlinkTarget(); $link_path = $symlink->getCurrentPath(); switch ($symlink->getType()) { case ArcanistDiffChangeType::TYPE_ADD: case ArcanistDiffChangeType::TYPE_CHANGE: case ArcanistDiffChangeType::TYPE_MOVE_HERE: case ArcanistDiffChangeType::TYPE_COPY_HERE: execx( '(cd %s && ln -sf %s %s)', $repository_api->getPath(), $link_target, $link_path); break; } } foreach ($patches as $path => $patch) { $err = null; if ($patch) { $tmp = new TempFile(); Filesystem::writeFile($tmp, $patch); passthru( csprintf( '(cd %s; patch -p0 < %s)', $repository_api->getPath(), $tmp), $err); } else { passthru( csprintf( '(cd %s; touch %s)', $repository_api->getPath(), $path), $err); } if ($err) { $patch_err = max($patch_err, $err); } } foreach ($adds as $add) { passthru( csprintf( '(cd %s; svn add %s)', $repository_api->getPath(), $add)); } foreach ($propset as $path => $changes) { foreach ($changes as $prop => $value) { if ($prop == 'unix:filemode') { // Setting this property also changes the file mode. $prop = 'svn:executable'; $value = (octdec($value) & 0111 ? 'on' : null); } if ($value === null) { passthru( csprintf( '(cd %s; svn propdel %s %s)', $repository_api->getPath(), $prop, $path)); } else { passthru( csprintf( '(cd %s; svn propset %s %s %s)', $repository_api->getPath(), $prop, $value, $path)); } } } if ($patch_err == 0) { echo phutil_console_format( "** OKAY ** Successfully applied patch ". "to the working copy.\n"); } else { echo phutil_console_format( "\n\n** WARNING ** Some hunks could not be applied ". "cleanly by the unix 'patch' utility. Your working copy may be ". "different from the revision's base, or you may be in the wrong ". "subdirectory. You can export the raw patch file using ". "'arc export --unified', and then try to apply it by fiddling with ". "options to 'patch' (particularly, -p), or manually. The output ". "above, from 'patch', may be helpful in figuring out what went ". "wrong.\n"); } return $patch_err; } else if ($repository_api instanceof ArcanistGitAPI) { $future = $repository_api->execFutureLocal( 'apply --index --reject'); $future->write($bundle->toGitPatch()); try { $future->resolvex(); } catch (CommandException $ex) { echo phutil_console_format( "\n** Patch Failed! **\n"); $stderr = $ex->getStdErr(); if (preg_match('/already exists in working directory/', $stderr)) { echo phutil_console_wrap( phutil_console_format( "\n** WARNING ** This patch may have failed ". "because it attempts to change the case of a filename (for ". "instance, from 'example.c' to 'Example.c'). Git cannot apply ". "patches like this on case-insensitive filesystems. You must ". "apply this patch manually.\n")); } throw $ex; } if ($this->shouldCommit()) { $commit_message = $this->getCommitMessage($bundle); $future = $repository_api->execFutureLocal( 'commit -a -F -'); $future->write($commit_message); $future->resolvex(); $verb = 'committed'; } else { $verb = 'applied'; } if ($this->canBranch() && !$this->shouldBranch() && $has_base_revision) { $repository_api->execxLocal('checkout %s', $original_branch); $ex = null; try { $repository_api->execxLocal('cherry-pick %s', $new_branch); } catch (Exception $ex) {} $repository_api->execxLocal('branch -D %s', $new_branch); if ($ex) { echo phutil_console_format( "\n** Cherry Pick Failed!**\n"); throw $ex; } } echo phutil_console_format( "** OKAY ** Successfully {$verb} patch.\n"); } else if ($repository_api instanceof ArcanistMercurialAPI) { $future = $repository_api->execFutureLocal( 'import --no-commit -'); $future->write($bundle->toGitPatch()); try { $future->resolvex(); } catch (CommandException $ex) { echo phutil_console_format( "\n** Patch Failed! **\n"); $stderr = $ex->getStdErr(); if (preg_match('/case-folding collision/', $stderr)) { echo phutil_console_wrap( phutil_console_format( "\n** WARNING ** This patch may have failed ". "because it attempts to change the case of a filename (for ". "instance, from 'example.c' to 'Example.c'). Mercurial cannot ". "apply patches like this on case-insensitive filesystems. You ". "must apply this patch manually.\n")); } throw $ex; } if ($this->shouldCommit()) { $commit_message = $this->getCommitMessage($bundle); $future = $repository_api->execFutureLocal( 'commit -A -l -'); $future->write($commit_message); $future->resolvex(); $verb = 'committed'; } else { $verb = 'applied'; } echo phutil_console_format( "** OKAY ** Successfully {$verb} patch.\n"); } else { throw new Exception('Unknown version control system.'); } return 0; } private function getCommitMessage(ArcanistBundle $bundle) { $revision_id = $bundle->getRevisionID(); $commit_message = null; $prompt_message = null; // if we have a revision id the commit message is in differential // TODO: See T848 for the authenticated stuff. if ($revision_id && $this->isConduitAuthenticated()) { $conduit = $this->getConduit(); $commit_message = $conduit->callMethodSynchronous( 'differential.getcommitmessage', array( 'revision_id' => $revision_id, )); $prompt_message = " Note arcanist failed to load the commit message ". "from differential for revision D{$revision_id}."; } // no revision id or failed to fetch commit message so get it from the // user on the command line if (!$commit_message) { $template = "\n\n". "# Enter a commit message for this patch. If you just want to apply ". "the patch to the working copy without committing, re-run arc patch ". "with the --nocommit flag.". $prompt_message. "\n"; $commit_message = $this->newInteractiveEditor($template) ->setName('arcanist-patch-commit-message') ->editInteractively(); $commit_message = ArcanistCommentRemover::removeComments($commit_message); if (!strlen(trim($commit_message))) { throw new ArcanistUserAbortException(); } } return $commit_message; } public function getShellCompletions(array $argv) { // TODO: Pull open diffs from 'arc list'? return array('ARGUMENT'); } /** * Do the best we can to prevent PEBKAC and id10t issues. */ private function sanityCheck(ArcanistBundle $bundle) { + $repository_api = $this->getRepositoryAPI(); + + if ($repository_api->supportsRelativeLocalCommits()) { + $repository_api->setDefaultBaseCommit(); + } // Require clean working copy $this->requireCleanWorkingCopy(); // Check to see if the bundle's project id matches the working copy // project id $bundle_project_id = $bundle->getProjectID(); $working_copy_project_id = $this->getWorkingCopy()->getProjectID(); if (empty($bundle_project_id)) { // this means $source is SOURCE_PATCH || SOURCE_BUNDLE w/ $version = 0 // they don't come with a project id so just do nothing } else if ($bundle_project_id != $working_copy_project_id) { if ($working_copy_project_id) { $issue = "This patch is for the '{$bundle_project_id}' project, but the ". "working copy belongs to the '{$working_copy_project_id}' project."; } else { $issue = "This patch is for the '{$bundle_project_id}' project, but the ". "working copy does not have an '.arcconfig' file to identify which ". "project it belongs to."; } $ok = phutil_console_confirm( "{$issue} Still try to apply the patch?", $default_no = false ); if (!$ok) { throw new ArcanistUserAbortException(); } } // Check to see if the bundle's base revision matches the working copy // base revision - $repository_api = $this->getRepositoryAPI(); if ($repository_api->supportsRelativeLocalCommits()) { $bundle_base_rev = $bundle->getBaseRevision(); if (empty($bundle_base_rev)) { // this means $source is SOURCE_PATCH || SOURCE_BUNDLE w/ $version < 2 // they don't have a base rev so just do nothing $commit_exists = true; } else { $commit_exists = $repository_api->hasLocalCommit($bundle_base_rev); } if (!$commit_exists) { // we have a problem...! lots of work because we need to ask // differential for revision information for these base revisions // to improve our error message. $bundle_base_rev_str = null; $source_base_rev = $repository_api->getWorkingCopyRevision(); $source_base_rev_str = null; if ($repository_api instanceof ArcanistGitAPI) { $hash_type = ArcanistDifferentialRevisionHash::HASH_GIT_COMMIT; } else if ($repository_api instanceof ArcanistMercurialAPI) { $hash_type = ArcanistDifferentialRevisionHash::HASH_MERCURIAL_COMMIT; } else { $hash_type = null; } if ($hash_type) { // 2 round trips because even though we could send off one query // we wouldn't be able to tell which revisions were for which hash $hash = array($hash_type, $bundle_base_rev); $bundle_revision = $this->loadRevisionFromHash($hash); $hash = array($hash_type, $source_base_rev); $source_revision = $this->loadRevisionFromHash($hash); if ($bundle_revision) { $bundle_base_rev_str = $bundle_base_rev . ' \ D' . $bundle_revision['id']; } if ($source_revision) { $source_base_rev_str = $source_base_rev . ' \ D' . $source_revision['id']; } } $bundle_base_rev_str = nonempty($bundle_base_rev_str, $bundle_base_rev); $source_base_rev_str = nonempty($source_base_rev_str, $source_base_rev); $ok = phutil_console_confirm( "This diff is against commit {$bundle_base_rev_str}, but the ". "commit is nowhere in the working copy. Try to apply it against ". "the current working copy state? ({$source_base_rev_str})", $default_no = false ); if (!$ok) { throw new ArcanistUserAbortException(); } } } // TODO -- more sanity checks here } /** * Create parent directories one at a time, since we need to "svn add" each * one. (Technically we could "svn add" just the topmost new directory.) */ private function createParentDirectoryOf($path) { $repository_api = $this->getRepositoryAPI(); $dir = dirname($path); if (Filesystem::pathExists($dir)) { return; } else { // Make sure the parent directory exists before we make this one. $this->createParentDirectoryOf($dir); execx( '(cd %s && mkdir %s)', $repository_api->getPath(), $dir); passthru( csprintf( '(cd %s && svn add %s)', $repository_api->getPath(), $dir)); } } private function loadRevisionFromHash($hash) { // TODO -- de-hack this as permissions become more clear with things // like T848 (add scope to OAuth) if (!$this->isConduitAuthenticated()) { return null; } $conduit = $this->getConduit(); $revisions = $conduit->callMethodSynchronous( 'differential.query', array( 'commitHashes' => array($hash), ) ); // grab the latest closed revision only $found_revision = null; $revisions = isort($revisions, 'dateModified'); foreach ($revisions as $revision) { if ($revision['status'] == ArcanistDifferentialRevisionStatus::CLOSED) { $found_revision = $revision; } } return $found_revision; } } diff --git a/src/workflow/ArcanistUpgradeWorkflow.php b/src/workflow/ArcanistUpgradeWorkflow.php index 554ecbee..47bd3ba5 100644 --- a/src/workflow/ArcanistUpgradeWorkflow.php +++ b/src/workflow/ArcanistUpgradeWorkflow.php @@ -1,102 +1,102 @@ $root) { echo "Upgrading {$lib}...\n"; if (!Filesystem::pathExists($root.'/.git')) { throw new ArcanistUsageException( "{$lib} must be in its git working copy to be automatically ". "upgraded. This copy of {$lib} (in '{$root}') is not in a git ". "working copy."); } $working_copy = ArcanistWorkingCopyIdentity::newFromPath($root); $repository_api = ArcanistRepositoryAPI::newAPIFromWorkingCopyIdentity( $working_copy); - // Force the range to HEAD^..HEAD, which is meaningless but keeps us - // from triggering "base" rules or other commit range resolution rules - // that might prompt the user when we pull the working copy status. - $repository_api->setRelativeCommit('HEAD^'); + if ($repository_api->supportsRelativeLocalCommits()) { + $repository_api->setDefaultBaseCommit(); + } + $this->setRepositoryAPI($repository_api); // Require no local changes. $this->requireCleanWorkingCopy(); // Require the library be on master. $branch_name = $repository_api->getBranchName(); if ($branch_name != 'master') { throw new ArcanistUsageException( "{$lib} must be on branch 'master' to be automatically upgraded. ". "This copy of {$lib} (in '{$root}') is on branch '{$branch_name}'."); } chdir($root); try { phutil_passthru('git pull --rebase'); } catch (Exception $ex) { phutil_passthru('git rebase --abort'); throw $ex; } } echo phutil_console_wrap( phutil_console_format( "**Updated!** Your copy of arc is now up to date.\n")); return 0; } }