diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index 111681e2..66ed8b35 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -13,6 +13,7 @@ phutil_register_library_map(array( 'ArcanistApacheLicenseLinterTestCase' => 'lint/linter/apachelicense/__tests__', 'ArcanistBaseUnitTestEngine' => 'unit/engine/base', 'ArcanistBaseWorkflow' => 'workflow/base', + 'ArcanistBranchWorkflow' => 'workflow/branch', 'ArcanistBundle' => 'parser/bundle', 'ArcanistCallConduitWorkflow' => 'workflow/call-conduit', 'ArcanistChooseInvalidRevisionException' => 'exception', @@ -77,6 +78,7 @@ phutil_register_library_map(array( 'ArcanistWorkingCopyIdentity' => 'workingcopyidentity', 'ArcanistXHPASTLinter' => 'lint/linter/xhpast', 'ArcanistXHPASTLinterTestCase' => 'lint/linter/xhpast/__tests__', + 'BranchInfo' => 'branch', 'PhutilLintEngine' => 'lint/engine/phutil', 'PhutilModuleRequirements' => 'parser/phutilmodule', 'PhutilUnitTestEngine' => 'unit/engine/phutil', @@ -91,6 +93,7 @@ phutil_register_library_map(array( 'ArcanistAmendWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistApacheLicenseLinter' => 'ArcanistLicenseLinter', 'ArcanistApacheLicenseLinterTestCase' => 'ArcanistLinterTestCase', + 'ArcanistBranchWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistCallConduitWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistCommitWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistCoverWorkflow' => 'ArcanistBaseWorkflow', diff --git a/src/branch/BranchInfo.php b/src/branch/BranchInfo.php new file mode 100644 index 00000000..54beae10 --- /dev/null +++ b/src/branch/BranchInfo.php @@ -0,0 +1,166 @@ +getAllBranches(); + $branches = array(); + foreach ($branches_raw as $branch_raw) { + $branch_info = new BranchInfo($branch_raw['name']); + $branch_info->setSha1($branch_raw['sha1']); + if ($branch_raw['current']) { + $branch_info->setCurrent(); + } + $branches[] = $branch_info; + } + + $name_sha1_map = mpull($branches, 'getSha1', 'getName'); + $commits_list = $api->multigetCommitMessages( + array_unique(array_values($name_sha1_map)), + "%%ct%%n%%an%%n%%s%%n%%b"); //don't ask + foreach ($branches as $branch) { + $sha1 = $name_sha1_map[$branch->getName()]; + $branch->setSha1($sha1); + $branch->parseCommitMessage($commits_list[$sha1]); + } + $branches = msort($branches, 'getCommitTime'); + return $branches; + } + + public function __construct($branch_name) { + $this->branchName = $branch_name; + } + + public function setSha1($sha1) { + $this->sha1 = $sha1; + return $this; + } + + public function getSha1() { + return $this->sha1; + } + + public function setCurrent() { + $this->currentHead = true; + } + + public function isCurrentHead() { + return $this->currentHead; + } + + + public function setStatus($status) { + $this->status = $status; + } + + public function getStatus() { + return $this->status; + } + + public function getRevisionId() { + return $this->revisionID; + } + + public function getCommitTime() { + return $this->commitTime; + } + + public function getCommitSubject() { + return $this->commitSubject; + } + + public function getCommitAuthor() { + return $this->commitAuthor; + } + + public function getName() { + return $this->branchName; + } + + /** + * Based on the 'git show' output extracts the commit date, author, + * subject nad Differential revision . + * 'Differential Revision:' + * + * @param string message output of git show -s --format="format:%ct%n%cn%n%b" + */ + public function parseCommitMessage($message) { + $message_lines = explode("\n", trim($message)); + $this->commitTime = $message_lines[0]; + $this->commitAuthor = $message_lines[1]; + $this->commitSubject = trim($message_lines[2]); + $this->revisionID = + ArcanistDifferentialCommitMessage::newFromRawCorpus($message) + ->getRevisionID(); + } + + public function getFormattedName() { + $res = ""; + if ($this->currentHead) { + $res = '* '; + } + $res .= $this->branchName; + return phutil_console_format('**%s**', $res); + + } + + /** + * Generates a colored status name + */ + public function getFormattedStatus() { + return phutil_console_format( + 'getColorForStatus().'>%s', + $this->status); + } + + /** + * Assigns a pretty color based on the status + */ + private function getColorForStatus() { + static $status_to_color = array( + 'Committed' => 'cyan', + 'Needs Review' => 'magenta', + 'Needs Revision' => 'red', + 'Accepted' => 'green', + 'No Revision' => 'blue', + 'Abandoned' => 'default', + ); + return idx($status_to_color, $this->status, 'default'); + } + +} diff --git a/src/branch/__init__.php b/src/branch/__init__.php new file mode 100644 index 00000000..e5ed4c09 --- /dev/null +++ b/src/branch/__init__.php @@ -0,0 +1,15 @@ +getPath()); + $lines = explode("\n", trim($branch_info)); + $result = array(); + foreach ($lines as $line) { + $match = array(); + $branch = array(); + preg_match('/^(\*?)\s*(\S+)/', $line, $match); + $branch['current'] = !empty($match[1]); + $branch['name'] = $match[2]; + $result[] = $branch; + } + $all_names = ipull($result, 'name'); + $names_list = implode(' ', $all_names); + // Calling 'git branch' first and then 'git rev-parse' is way faster than + // 'git branch -v' for some reason. + list($sha1s_string) = execx( + "cd %s && git rev-parse $names_list", + $this->path); + $sha1_map = array_combine($all_names, explode("\n", trim($sha1s_string))); + foreach ($result as &$branch) { + $branch['sha1'] = $sha1_map[$branch['name']]; + } + return $result; + } + + public function multigetRevForBranch($branch_names) { + $names_list = implode(' ', $branch_names); + list($sha1s_string) = execx( + "cd %s && git rev-parse $names_list", + $this->path); + return array_combine($branch_names, explode("\n", trim($sha1s_string))); + } + + /** + * Returns git commit messages for the given revisions, + * in the specified format (see git show --help for options). + * + * @param array $revs a list of commit hashes + * @param string $format the format to show messages in + */ + public function multigetCommitMessages($revs, $format) { + $delimiter = "%%x00"; + $revs_list = implode(' ', $revs); + $show_command = + "git show -s --pretty=\"format:$format$delimiter\" $revs_list"; + list($commits_string) = execx( + "cd %s && $show_command", + $this->getPath()); + $commits_list = array_slice(explode("\0", $commits_string), 0, -1); + $commits_list = array_combine($revs, $commits_list); + return $commits_list; + } + + public function getRepositoryOwner() { + list($owner) = execx( + 'cd %s && git config --get user.name', + $this->getPath()); + return trim($owner); + } + } diff --git a/src/repository/api/git/__init__.php b/src/repository/api/git/__init__.php index 0ee9f3f6..6f6dae56 100644 --- a/src/repository/api/git/__init__.php +++ b/src/repository/api/git/__init__.php @@ -9,6 +9,7 @@ phutil_require_module('arcanist', 'repository/api/base'); phutil_require_module('phutil', 'future/exec'); +phutil_require_module('phutil', 'utils'); phutil_require_source('ArcanistGitAPI.php'); diff --git a/src/workflow/branch/ArcanistBranchWorkflow.php b/src/workflow/branch/ArcanistBranchWorkflow.php new file mode 100644 index 00000000..0f2f3369 --- /dev/null +++ b/src/workflow/branch/ArcanistBranchWorkflow.php @@ -0,0 +1,152 @@ + array( + 'help' => + "Include committed and abandoned revisions", + ), + ); + } + + public function run() { + $repository_api = $this->getRepositoryAPI(); + if (!($repository_api instanceof ArcanistGitAPI)) { + throw new ArcanistUsageException( + "arc branch is only supported under git." + ); + } + + $this->branches = BranchInfo::loadAll($repository_api); + $all_revisions = array_unique( + array_filter(mpull($this->branches, 'getRevisionId'))); + $revision_status = $this->loadDifferentialStatuses($all_revisions); + $owner = $repository_api->getRepositoryOwner(); + foreach ($this->branches as $branch) { + if ($branch->getCommitAuthor() != $owner) { + $branch->setStatus('Not Yours'); + continue; + } + $rev_id = $branch->getRevisionId(); + if ($rev_id) { + $status = idx($revision_status, $rev_id, 'Unknown Status'); + $branch->setStatus($status); + } else { + $branch->setStatus('No Revision'); + } + } + if (!$this->getArgument('view-all')) { + $this->filterOutFinished(); + } + $this->printInColumns(); + } + + + + /** + * Makes a conduit call to differential to find out revision statuses + * based on their IDs + */ + private function loadDifferentialStatuses($rev_ids) { + $conduit = $this->getConduit(); + $revision_future = $conduit->callMethod( + 'differential.find', + array( + 'guids' => $rev_ids, + 'query' => 'revision-ids', + )); + $revisions = array(); + foreach ($revision_future->resolve() as $revision_dict) { + $revisions[] = ArcanistDifferentialRevisionRef::newFromDictionary( + $revision_dict); + } + $statuses = mpull($revisions, 'getStatusName', 'getId'); + return $statuses; + } + + /** + * Removes the branches with status either committed or abandoned. + */ + private function filterOutFinished() { + foreach ($this->branches as $id => $branch) { + if ($branch->isCurrentHead() ) { + continue; //never filter the current branch + } + $status = $branch->getStatus(); + if ($status == 'Committed' || $status == 'Abandoned') { + unset($this->branches[$id]); + } + } + } + + public function printInColumns() { + $longest_name = 0; + $longest_status = 0; + foreach ($this->branches as $branch) { + $longest_name = max(strlen($branch->getFormattedName()), $longest_name); + $longest_status = max(strlen($branch->getStatus()), $longest_status); + } + foreach ($this->branches as $branch) { + $name_markdown = $branch->getFormattedName(); + $status_markdown = $branch->getFormattedStatus(); + $subject = $branch->getCommitSubject(); + $subject_pad = $longest_status - strlen($branch->getStatus()) + 4; + $name_markdown = + str_pad($name_markdown, $longest_name + 4, ' '); + $subject = + str_pad($subject, strlen($subject) + $subject_pad, ' ', STR_PAD_LEFT); + echo "$name_markdown $status_markdown $subject\n"; + } + } +} diff --git a/src/workflow/branch/__init__.php b/src/workflow/branch/__init__.php new file mode 100644 index 00000000..d482a62e --- /dev/null +++ b/src/workflow/branch/__init__.php @@ -0,0 +1,18 @@ +