diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index 33e38b8d..6eebb90d 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -103,6 +103,7 @@ phutil_register_library_map(array( 'ArcanistPyFlakesLinter' => 'lint/linter/ArcanistPyFlakesLinter.php', 'ArcanistPyLintLinter' => 'lint/linter/ArcanistPyLintLinter.php', 'ArcanistRepositoryAPI' => 'repository/api/ArcanistRepositoryAPI.php', + 'ArcanistRepositoryAPIStateTestCase' => 'repository/api/__tests__/ArcanistRepositoryAPIStateTestCase.php', 'ArcanistRubyLinter' => 'lint/linter/ArcanistRubyLinter.php', 'ArcanistRubyLinterTestCase' => 'lint/linter/__tests__/ArcanistRubyLinterTestCase.php', 'ArcanistScalaSBTLinter' => 'lint/linter/ArcanistScalaSBTLinter.php', @@ -216,6 +217,7 @@ phutil_register_library_map(array( 'ArcanistPhutilTestTerminatedException' => 'Exception', 'ArcanistPyFlakesLinter' => 'ArcanistLinter', 'ArcanistPyLintLinter' => 'ArcanistLinter', + 'ArcanistRepositoryAPIStateTestCase' => 'ArcanistTestCase', 'ArcanistRubyLinter' => 'ArcanistLinter', 'ArcanistRubyLinterTestCase' => 'ArcanistArcanistLinterTestCase', 'ArcanistScalaSBTLinter' => 'ArcanistLinter', diff --git a/src/repository/api/ArcanistGitAPI.php b/src/repository/api/ArcanistGitAPI.php index 1cfd5d26..f3de6260 100644 --- a/src/repository/api/ArcanistGitAPI.php +++ b/src/repository/api/ArcanistGitAPI.php @@ -7,14 +7,13 @@ */ final class ArcanistGitAPI extends ArcanistRepositoryAPI { - private $status; private $relativeCommit = null; private $repositoryHasNoCommits = false; const SEARCH_LENGTH_FOR_PARENT_REVISIONS = 16; /** * For the repository's initial commit, 'git diff HEAD^' and similar do - * not work. Using this instead does work. + * not work. Using this instead does work; it is the hash of the empty tree. */ const GIT_MAGIC_ROOT_COMMIT = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; @@ -384,92 +383,81 @@ final class ArcanistGitAPI extends ArcanistRepositoryAPI { return rtrim($stdout); } - public function getWorkingCopyStatus() { - if (!isset($this->status)) { + protected function buildUncommittedStatus() { + $diff_options = $this->getDiffBaseOptions(); - $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; + if ($this->repositoryHasNoCommits) { + $diff_base = self::GIT_MAGIC_ROOT_COMMIT; + } else { + $diff_base = 'HEAD'; } - return $this->status; + // Find uncommitted changes. + $uncommitted_future = $this->buildLocalFuture( + array( + 'diff %C --raw %s --', + $diff_options, + $diff_base, + )); + + $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( + $uncommitted_future, + $untracked_future, + $unstaged_future, + ); + + Futures($futures)->resolveAll(); + + $result = new PhutilArrayWithDefaultValue(); + + list($stdout) = $uncommitted_future->resolvex(); + $uncommitted_files = $this->parseGitStatus($stdout); + foreach ($uncommitted_files as $path => $mask) { + $result[$path] |= ($mask | self::FLAG_UNCOMMITTED); + } + + list($stdout) = $untracked_future->resolvex(); + $stdout = rtrim($stdout, "\n"); + if (strlen($stdout)) { + $stdout = explode("\n", $stdout); + foreach ($stdout as $path) { + $result[$path] |= self::FLAG_UNTRACKED; + } + } + + list($stdout, $stderr) = $unstaged_future->resolvex(); + $stdout = rtrim($stdout, "\n"); + if (strlen($stdout)) { + $stdout = explode("\n", $stdout); + foreach ($stdout as $path) { + $result[$path] |= self::FLAG_UNSTAGED; + } + } + + return $result->toArray(); + } + + protected function buildCommitRangeStatus() { + list($stdout, $stderr) = $this->execxLocal( + 'diff %C --raw %s --', + $this->getDiffBaseOptions(), + $this->getRelativeCommit()); + + return $this->parseGitStatus($stdout); } public function getGitConfig($key, $default = null) { @@ -497,6 +485,10 @@ final class ArcanistGitAPI extends ArcanistRepositoryAPI { $this->execxLocal( 'commit --allow-empty-message -F %s', $tmp_file); + + $this->reloadWorkingCopy(); + + return $this; } public function amendCommit($message) { diff --git a/src/repository/api/ArcanistMercurialAPI.php b/src/repository/api/ArcanistMercurialAPI.php index e79e121d..50e28014 100644 --- a/src/repository/api/ArcanistMercurialAPI.php +++ b/src/repository/api/ArcanistMercurialAPI.php @@ -7,7 +7,6 @@ */ final class ArcanistMercurialAPI extends ArcanistRepositoryAPI { - private $status; private $base; private $relativeCommit; private $branch; @@ -296,70 +295,66 @@ final class ArcanistMercurialAPI extends ArcanistRepositoryAPI { return $blame; } - public function getWorkingCopyStatus() { + protected function buildUncommittedStatus() { + list($stdout) = $this->execxLocal('status'); - 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. + $results = new PhutilArrayWithDefaultValue(); - // Execute status in the background - $status_future = $this->buildLocalFuture(array('status')); - $status_future->start(); - - $diff = $this->getFullMercurialDiff(); - - if (!$diff) { - $this->status = array(); - return $this->status; + $working_status = ArcanistMercurialParser::parseMercurialStatus($stdout); + foreach ($working_status as $path => $mask) { + if (!($mask & ArcanistRepositoryAPI::FLAG_UNTRACKED)) { + // Mark tracked files as uncommitted. + $mask |= self::FLAG_UNCOMMITTED; } - $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) = $status_future->resolvex(); - - $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; + $results[$path] |= $mask; } - return $this->status; + return $results->toArray(); + } + + protected function buildCommitRangeStatus() { + // TODO: Possibly we should use "hg status --rev X --rev ." for this + // instead, but we must run "hg diff" later anyway in most cases, so + // building and caching it shouldn't hurt us. + + $diff = $this->getFullMercurialDiff(); + if (!$diff) { + return array(); + } + + $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; + } + + return $status_map; + } + + protected function didReloadWorkingCopy() { + // Diffs are against ".", so we need to drop the cache if we change the + // working copy. + $this->rawDiffCache = array(); } private function getDiffOptions() { @@ -629,6 +624,7 @@ final class ArcanistMercurialAPI extends ArcanistRepositoryAPI { $this->execxLocal( 'commit -l %s', $tmp_file); + $this->reloadWorkingCopy(); } public function amendCommit($message) { diff --git a/src/repository/api/ArcanistRepositoryAPI.php b/src/repository/api/ArcanistRepositoryAPI.php index 0e4334e5..9af523ab 100644 --- a/src/repository/api/ArcanistRepositoryAPI.php +++ b/src/repository/api/ArcanistRepositoryAPI.php @@ -3,6 +3,7 @@ /** * Interfaces with the VCS in the working copy. * + * @task status Path Status * @group workingcopy */ abstract class ArcanistRepositoryAPI { @@ -30,6 +31,9 @@ abstract class ArcanistRepositoryAPI { private $workingCopyIdentity; private $baseCommitArgumentRules; + private $uncommittedStatusCache; + private $commitRangeStatusCache; + abstract public function getSourceControlSystemName(); public function getDiffLinesOfContext() { @@ -76,7 +80,9 @@ abstract class ArcanistRepositoryAPI { } // check if we're in an svn working copy - list($err) = exec_manual('svn info'); + list($err) = id(new ExecFuture('svn info')) + ->setCWD($root) + ->resolve(); if (!$err) { $api = new ArcanistSubversionAPI($root); $api->workingCopyIdentity = $working_copy; @@ -101,36 +107,174 @@ abstract class ArcanistRepositoryAPI { } } - public function getUntrackedChanges() { - return $this->getWorkingCopyFilesWithMask(self::FLAG_UNTRACKED); + +/* -( Path Status )-------------------------------------------------------- */ + + + abstract protected function buildUncommittedStatus(); + abstract protected function buildCommitRangeStatus(); + + + /** + * Get a list of uncommitted paths in the working copy that have been changed + * or are affected by other status effects, like conflicts or untracked + * files. + * + * Convenience methods @{method:getUntrackedChanges}, + * @{method:getUnstagedChanges}, @{method:getUncommittedChanges}, + * @{method:getMergeConflicts}, and @{method:getIncompleteChanges} allow + * simpler selection of paths in a specific state. + * + * This method returns a map of paths to bitmasks with status, using + * `FLAG_` constants. For example: + * + * array( + * 'some/uncommitted/file.txt' => ArcanistRepositoryAPI::FLAG_UNSTAGED, + * ); + * + * A file may be in several states. Not all states are possible with all + * version control systems. + * + * @return map Map of paths, see above. + * @task status + */ + final public function getUncommittedStatus() { + if ($this->uncommittedStatusCache === null) { + $status = $this->buildUncommittedStatus();; + ksort($status); + $this->uncommittedStatusCache = $status; + } + return $this->uncommittedStatusCache; } - public function getUnstagedChanges() { - return $this->getWorkingCopyFilesWithMask(self::FLAG_UNSTAGED); + + /** + * @task status + */ + final public function getUntrackedChanges() { + return $this->getUncommittedPathsWithMask(self::FLAG_UNTRACKED); } - public function getUncommittedChanges() { - return $this->getWorkingCopyFilesWithMask(self::FLAG_UNCOMMITTED); + + /** + * @task status + */ + final public function getUnstagedChanges() { + return $this->getUncommittedPathsWithMask(self::FLAG_UNSTAGED); } - public function getMergeConflicts() { - return $this->getWorkingCopyFilesWithMask(self::FLAG_CONFLICT); + + /** + * @task status + */ + final public function getUncommittedChanges() { + return $this->getUncommittedPathsWithMask(self::FLAG_UNCOMMITTED); } - public function getIncompleteChanges() { - return $this->getWorkingCopyFilesWithMask(self::FLAG_INCOMPLETE); + + /** + * @task status + */ + final public function getMergeConflicts() { + return $this->getUncommittedPathsWithMask(self::FLAG_CONFLICT); } - private function getWorkingCopyFilesWithMask($mask) { + + /** + * @task status + */ + final public function getIncompleteChanges() { + return $this->getUncommittedPathsWithMask(self::FLAG_INCOMPLETE); + } + + + /** + * @task status + */ + private function getUncommittedPathsWithMask($mask) { $match = array(); - foreach ($this->getWorkingCopyStatus() as $file => $flags) { + foreach ($this->getUncommittedStatus() as $path => $flags) { if ($flags & $mask) { - $match[] = $file; + $match[] = $path; } } return $match; } + + /** + * Get a list of paths affected by the commits in the current commit range. + * + * See @{method:getUncommittedStatus} for a description of the return value. + * + * @return map Map from paths to status. + * @task status + */ + final public function getCommitRangeStatus() { + if ($this->commitRangeStatusCache === null) { + $status = $this->buildCommitRangeStatus(); + ksort($status); + $this->commitRangeStatusCache = $status; + } + return $this->commitRangeStatusCache; + } + + + /** + * Get a list of paths affected by commits in the current commit range, or + * uncommitted changes in the working copy. See @{method:getUncommittedStatus} + * or @{method:getCommitRangeStatus} to retreive smaller parts of the status. + * + * See @{method:getUncommittedStatus} for a description of the return value. + * + * @return map Map from paths to status. + * @task status + */ + final public function getWorkingCopyStatus() { + $range_status = $this->getCommitRangeStatus(); + $uncommitted_status = $this->getUncommittedStatus(); + + $result = new PhutilArrayWithDefaultValue($range_status); + foreach ($uncommitted_status as $path => $mask) { + $result[$path] |= $mask; + } + + $result = $result->toArray(); + ksort($result); + return $result; + } + + + /** + * Drops caches after changes to the working copy. By default, some queries + * against the working copy are cached. They + * + * @return this + * @task status + */ + final public function reloadWorkingCopy() { + $this->uncommittedStatusCache = null; + $this->commitRangeStatusCache = null; + + $this->didReloadWorkingCopy(); + + return $this; + } + + + /** + * Hook for implementations to dirty working copy caches after the working + * copy has been updated. + * + * @return this + * @task status + */ + protected function didReloadWorkingCopy() { + return; + } + + + private static function discoverGitBaseDirectory($root) { try { @@ -148,13 +292,14 @@ abstract class ArcanistRepositoryAPI { } } + /** * @return Traversable */ abstract public function getAllFiles(); abstract public function getBlame($path); - abstract public function getWorkingCopyStatus(); + abstract public function getRawDiffText($path); abstract public function getOriginalFileData($path); abstract public function getCurrentFileData($path); diff --git a/src/repository/api/ArcanistSubversionAPI.php b/src/repository/api/ArcanistSubversionAPI.php index 82c9402e..7fae4703 100644 --- a/src/repository/api/ArcanistSubversionAPI.php +++ b/src/repository/api/ArcanistSubversionAPI.php @@ -46,7 +46,13 @@ final class ArcanistSubversionAPI extends ArcanistRepositoryAPI { return $future; } - public function getWorkingCopyStatus() { + protected function buildCommitRangeStatus() { + // In SVN, the commit range is always "uncommitted changes", so these + // statuses are equivalent. + return $this->getUncommittedStatus(); + } + + protected function buildUncommittedStatus() { return $this->getSVNStatus(); } diff --git a/src/repository/api/__tests__/ArcanistRepositoryAPIStateTestCase.php b/src/repository/api/__tests__/ArcanistRepositoryAPIStateTestCase.php new file mode 100644 index 00000000..e139d3d0 --- /dev/null +++ b/src/repository/api/__tests__/ArcanistRepositoryAPIStateTestCase.php @@ -0,0 +1,93 @@ +getPath(); + $working_copy = ArcanistWorkingCopyIdentity::newFromPath($fixture_path); + + $api = ArcanistRepositoryAPI::newAPIFromWorkingCopyIdentity( + $working_copy); + + if ($api->supportsRelativeLocalCommits()) { + $api->setDefaultBaseCommit(); + } + + $this->assertCorrectState($test, $api); + } + } + + private function assertCorrectState($test, ArcanistRepositoryAPI $api) { + $f_mod = ArcanistRepositoryAPI::FLAG_MODIFIED; + $f_add = ArcanistRepositoryAPI::FLAG_ADDED; + $f_del = ArcanistRepositoryAPI::FLAG_DELETED; + $f_unt = ArcanistRepositoryAPI::FLAG_UNTRACKED; + $f_con = ArcanistRepositoryAPI::FLAG_CONFLICT; + $f_mis = ArcanistRepositoryAPI::FLAG_MISSING; + $f_uns = ArcanistRepositoryAPI::FLAG_UNSTAGED; + $f_unc = ArcanistRepositoryAPI::FLAG_UNCOMMITTED; + $f_ext = ArcanistRepositoryAPI::FLAG_EXTERNALS; + $f_obs = ArcanistRepositoryAPI::FLAG_OBSTRUCTED; + $f_inc = ArcanistRepositoryAPI::FLAG_INCOMPLETE; + + switch ($test) { + case 'svn_basic.svn.tgz': + $expect = array( + 'ADDED' => $f_add, + 'COPIED_TO' => $f_add, + 'DELETED' => $f_del, + 'MODIFIED' => $f_mod, + 'MOVED' => $f_del, + 'MOVED_TO' => $f_add, + 'PROPCHANGE' => $f_mod, + 'UNTRACKED' => $f_unt, + ); + $this->assertEqual($expect, $api->getUncommittedStatus()); + $this->assertEqual($expect, $api->getCommitRangeStatus()); + break; + case 'git_basic.git.tgz': + $expect_uncommitted = array( + 'UNCOMMITTED' => $f_add | $f_unc, + 'UNSTAGED' => $f_mod | $f_uns | $f_unc, + 'UNTRACKED' => $f_unt, + ); + $this->assertEqual($expect_uncommitted, $api->getUncommittedStatus()); + + $expect_range = array( + 'ADDED' => $f_add, + 'DELETED' => $f_del, + 'MODIFIED' => $f_mod, + 'UNCOMMITTED' => $f_add, + 'UNSTAGED' => $f_add, + ); + $this->assertEqual($expect_range, $api->getCommitRangeStatus()); + break; + case 'hg_basic.hg.tgz': + $expect_uncommitted = array( + 'UNCOMMITTED' => $f_mod | $f_unc, + 'UNTRACKED' => $f_unt, + ); + $this->assertEqual($expect_uncommitted, $api->getUncommittedStatus()); + + $expect_range = array( + 'ADDED' => $f_add, + 'DELETED' => $f_del, + 'MODIFIED' => $f_mod, + 'UNCOMMITTED' => $f_add, + ); + $this->assertEqual($expect_range, $api->getCommitRangeStatus()); + break; + default: + throw new Exception( + "No test cases for working copy '{$test}'!"); + } + } + + +} diff --git a/src/repository/api/__tests__/state/git_basic.git.tgz b/src/repository/api/__tests__/state/git_basic.git.tgz new file mode 100644 index 00000000..d1841a8f Binary files /dev/null and b/src/repository/api/__tests__/state/git_basic.git.tgz differ diff --git a/src/repository/api/__tests__/state/hg_basic.hg.tgz b/src/repository/api/__tests__/state/hg_basic.hg.tgz new file mode 100644 index 00000000..1ae5935f Binary files /dev/null and b/src/repository/api/__tests__/state/hg_basic.hg.tgz differ diff --git a/src/repository/api/__tests__/state/svn_basic.svn.tgz b/src/repository/api/__tests__/state/svn_basic.svn.tgz new file mode 100644 index 00000000..1bb0a621 Binary files /dev/null and b/src/repository/api/__tests__/state/svn_basic.svn.tgz differ