diff --git a/src/repository/api/ArcanistGitAPI.php b/src/repository/api/ArcanistGitAPI.php --- a/src/repository/api/ArcanistGitAPI.php +++ b/src/repository/api/ArcanistGitAPI.php @@ -16,6 +16,9 @@ */ const GIT_MAGIC_ROOT_COMMIT = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; + private $symbolicHeadCommit = 'HEAD'; + private $resolvedHeadCommit; + public static function newHookAPI($root) { return new ArcanistGitAPI($root); } @@ -75,6 +78,20 @@ return !$this->repositoryHasNoCommits; } + /** + * Tests if a child commit is descendant of a parent commit. + * @param child commit SHA. + * @param parent commit SHA. + * @return bool + */ + public function isDescendant($child, $parent) { + list($common_ancestor) = + $this->execxLocal('merge-base %s %s', $child, $parent); + $common_ancestor = trim($common_ancestor, " \n\2"); + + return $common_ancestor == $parent && $common_ancestor != $child; + } + public function getLocalCommitInformation() { if ($this->repositoryHasNoCommits) { // Zero commits. @@ -106,7 +123,16 @@ // 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->getBaseCommit()); + $base_commit = $this->getBaseCommit(); + $head_commit = $this->getHeadCommit(); + if ($this->isDescendant($head_commit, $base_commit) === false) { + throw new Exception( + "base commit ${head_commit} is not a child of head commit ". + "${base_commit}"); + } + + $against = csprintf('%s --not %s', + $this->getHeadCommit(), $this->getBaseCommit()); } // NOTE: Windows escaping of "%" symbols apparently is inherently broken; @@ -161,8 +187,9 @@ } list($err, $merge_base) = $this->execManualLocal( - 'merge-base %s HEAD', - $symbolic_commit); + 'merge-base %s %s', + $symbolic_commit, + $this->getHeadCommit()); if ($err) { throw new ArcanistUsageException( "Unable to find any git commit named '{$symbolic_commit}' in ". @@ -170,8 +197,8 @@ } $this->setBaseCommitExplanation( - "it is the merge-base of '{$symbolic_commit}' and HEAD, as you ". - "explicitly specified."); + "it is the merge-base of '{$symbolic_commit}' and ". + "{$this->symbolicHeadCommit}, as you explicitly specified."); return trim($merge_base); } @@ -301,6 +328,44 @@ return trim($merge_base); } + public function getHeadCommit() { + if (!$this->supportsCommitRanges()) { + throw new ArcanistCapabilityNotSupportedException($this); + } + + if ($this->resolvedHeadCommit === null) { + $this->resolvedHeadCommit = + $this->resolveCommit($this->symbolicHeadCommit); + } + + return $this->resolvedHeadCommit; + } + + final public function setHeadCommit($symbolic_commit) { + $this->symbolicHeadCommit = $symbolic_commit; + $this->reloadCommitRange(); + return $this; + } + + /** + * Translates a symbolic commit (like "HEAD^") to a commit identifier. + * @param string_symbol commit. + * @return string the commit SHA. + */ + private function resolveCommit($symbolic_commit) { + list($err, $commit_hash) = $this->execManualLocal( + 'rev-parse %s', + $symbolic_commit); + + if ($err) { + throw new ArcanistUsageException( + "Unable to find any git commit named '{$symbolic_commit}' in ". + "this repository."); + } + + return trim($commit_hash); + } + private function getDiffFullOptions($detect_moves_and_renames = true) { $options = array( self::getDiffBaseOptions(), @@ -335,8 +400,9 @@ public function getFullGitDiff() { $options = $this->getDiffFullOptions(); list($stdout) = $this->execxLocal( - "diff {$options} %s --", - $this->getBaseCommit()); + "diff {$options} %s..%s --", + $this->getBaseCommit(), + $this->getHeadCommit()); return $stdout; } @@ -401,8 +467,9 @@ } else { // 2..N commits. list($stdout) = $this->execxLocal( - 'log --first-parent --format=medium %s..HEAD', - $this->getBaseCommit()); + 'log --first-parent --format=medium %s..%s', + $this->getBaseCommit(), + $this->getHeadCommit()); } return $stdout; } diff --git a/src/repository/api/ArcanistRepositoryAPI.php b/src/repository/api/ArcanistRepositoryAPI.php --- a/src/repository/api/ArcanistRepositoryAPI.php +++ b/src/repository/api/ArcanistRepositoryAPI.php @@ -575,6 +575,10 @@ return $this; } + public function setHeadCommit($symbolic_commit) { + throw new ArcanistCapabilityNotSupportedException($this); + } + final public function getBaseCommit() { if (!$this->supportsCommitRanges()) { throw new ArcanistCapabilityNotSupportedException($this); @@ -588,6 +592,10 @@ return $this->resolvedBaseCommit; } + public function getHeadCommit() { + throw new ArcanistCapabilityNotSupportedException($this); + } + final public function reloadCommitRange() { $this->resolvedBaseCommit = null; $this->baseCommitExplanation = null; diff --git a/src/workflow/ArcanistDiffWorkflow.php b/src/workflow/ArcanistDiffWorkflow.php --- a/src/workflow/ArcanistDiffWorkflow.php +++ b/src/workflow/ArcanistDiffWorkflow.php @@ -395,6 +395,18 @@ ), ), '*' => 'paths', + 'head' => array( + 'param' => 'commit', + 'help' => "specify the head commit.\n". + "This disables many Arcanist/Phabricator features which depend on ". + "having access to the working copy.", + 'supports' => array('git'), + 'conflicts' => array( + 'lintall' => '--head suppresses lint.', + 'advice' => '--head suppresses lint.', + ), + + ) ); if (phutil_is_windows()) { @@ -433,8 +445,19 @@ array_unshift($argv, '--ansi'); } - if ($this->getRepositoryAPI()->supportsCommitRanges()) { - $this->getRepositoryAPI()->getBaseCommit(); + $repo = $this->getRepositoryAPI(); + $head_commit = $this->getArgument('head', null); + $range_supported = $repo->supportsCommitRanges(); + if ($head_commit) { + if (!$range_supported) { + throw new Exception('ranged are not supported'); + } + + $repo->setHeadCommit($head_commit); + } + + if ($range_supported) { + $repo->getBaseCommit(); } $script = phutil_get_library_root('arcanist').'/../scripts/arcanist.php'; @@ -661,7 +684,9 @@ if ($repository_api instanceof ArcanistSubversionAPI) { $repository_api->limitStatusToPaths($this->getArgument('paths')); } - $this->requireCleanWorkingCopy(); + if (!$this->getArgument('range')) { + $this->requireCleanWorkingCopy(); + } } catch (ArcanistUncommittedChangesException $ex) { if ($repository_api instanceof ArcanistMercurialAPI) { $use_dirty_changes = false; @@ -1216,7 +1241,8 @@ private function runLint() { if ($this->getArgument('nolint') || $this->getArgument('only') || - $this->isRawDiffSource()) { + $this->isRawDiffSource() || + $this->getArgument('range')) { return ArcanistLintWorkflow::RESULT_SKIP; } @@ -1297,7 +1323,8 @@ private function runUnit() { if ($this->getArgument('nounit') || $this->getArgument('only') || - $this->isRawDiffSource()) { + $this->isRawDiffSource() || + $this->getArgument('range')) { return ArcanistUnitWorkflow::RESULT_SKIP; } diff --git a/src/workflow/ArcanistWhichWorkflow.php b/src/workflow/ArcanistWhichWorkflow.php --- a/src/workflow/ArcanistWhichWorkflow.php +++ b/src/workflow/ArcanistWhichWorkflow.php @@ -61,6 +61,10 @@ ), 'supports' => array('git', 'hg'), ), + 'head' => array( + 'param' => 'commit', + 'help' => 'specify the head commit.' + ), '*' => 'commit', ); } @@ -83,7 +87,19 @@ $repository_api->setBaseCommitArgumentRules( $this->getArgument('base', '')); - if ($repository_api->supportsCommitRanges()) { + $supports_ranges = $repository_api->supportsCommitRanges(); + + if ($this->getArgument('head')) { + if ($supports_ranges === false) { + throw new Exception('--head is not supported in this VCS'); + } + + $head_commit = $this->getArgument('head'); + $arg .= " --head ${head_commit}"; + $repository_api->setHeadCommit($head_commit); + } + + if ($supports_ranges) { $relative = $repository_api->getBaseCommit(); if ($this->getArgument('show-base')) {