diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -219,6 +219,7 @@ 'ArcanistGitCommitMessageHardpointQuery' => 'query/ArcanistGitCommitMessageHardpointQuery.php', 'ArcanistGitCommitSymbolCommitHardpointQuery' => 'ref/commit/ArcanistGitCommitSymbolCommitHardpointQuery.php', 'ArcanistGitLandEngine' => 'land/ArcanistGitLandEngine.php', + 'ArcanistGitLocalState' => 'repository/state/ArcanistGitLocalState.php', 'ArcanistGitUpstreamPath' => 'repository/api/ArcanistGitUpstreamPath.php', 'ArcanistGitWorkingCopy' => 'workingcopy/ArcanistGitWorkingCopy.php', 'ArcanistGitWorkingCopyRevisionHardpointQuery' => 'query/ArcanistGitWorkingCopyRevisionHardpointQuery.php', @@ -401,6 +402,7 @@ 'ArcanistRepositoryAPI' => 'repository/api/ArcanistRepositoryAPI.php', 'ArcanistRepositoryAPIMiscTestCase' => 'repository/api/__tests__/ArcanistRepositoryAPIMiscTestCase.php', 'ArcanistRepositoryAPIStateTestCase' => 'repository/api/__tests__/ArcanistRepositoryAPIStateTestCase.php', + 'ArcanistRepositoryLocalState' => 'repository/state/ArcanistRepositoryLocalState.php', 'ArcanistRepositoryRef' => 'ref/ArcanistRepositoryRef.php', 'ArcanistReusedAsIteratorXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistReusedAsIteratorXHPASTLinterRule.php', 'ArcanistReusedAsIteratorXHPASTLinterRuleTestCase' => 'lint/linter/xhpast/rules/__tests__/ArcanistReusedAsIteratorXHPASTLinterRuleTestCase.php', @@ -1227,6 +1229,7 @@ 'ArcanistGitCommitMessageHardpointQuery' => 'ArcanistWorkflowGitHardpointQuery', 'ArcanistGitCommitSymbolCommitHardpointQuery' => 'ArcanistWorkflowGitHardpointQuery', 'ArcanistGitLandEngine' => 'ArcanistLandEngine', + 'ArcanistGitLocalState' => 'ArcanistRepositoryLocalState', 'ArcanistGitUpstreamPath' => 'Phobject', 'ArcanistGitWorkingCopy' => 'ArcanistWorkingCopy', 'ArcanistGitWorkingCopyRevisionHardpointQuery' => 'ArcanistWorkflowGitHardpointQuery', @@ -1412,6 +1415,7 @@ 'ArcanistRepositoryAPI' => 'Phobject', 'ArcanistRepositoryAPIMiscTestCase' => 'PhutilTestCase', 'ArcanistRepositoryAPIStateTestCase' => 'PhutilTestCase', + 'ArcanistRepositoryLocalState' => 'Phobject', 'ArcanistRepositoryRef' => 'ArcanistRef', 'ArcanistReusedAsIteratorXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistReusedAsIteratorXHPASTLinterRuleTestCase' => 'ArcanistXHPASTLinterRuleTestCase', diff --git a/src/repository/state/ArcanistGitLocalState.php b/src/repository/state/ArcanistGitLocalState.php new file mode 100644 --- /dev/null +++ b/src/repository/state/ArcanistGitLocalState.php @@ -0,0 +1,147 @@ +localRef; + } + + public function getLocalPath() { + return $this->localPath; + } + + protected function executeSaveLocalState() { + $api = $this->getRepositoryAPI(); + + $commit = $api->getWorkingCopyRevision(); + + list($ref) = $api->execxLocal('rev-parse --abbrev-ref HEAD'); + $ref = trim($ref); + if ($ref === 'HEAD') { + $ref = null; + $where = pht( + 'Saving local state (at detached commit "%s").', + $this->getDisplayHash($commit)); + } else { + $where = pht( + 'Saving local state (on ref "%s" at commit "%s").', + $ref, + $this->getDisplayHash($commit)); + } + + $this->localRef = $ref; + $this->localCommit = $commit; + + if ($ref !== null) { + $this->localPath = $api->getPathToUpstream($ref); + } + + $log = $this->getWorkflow()->getLogEngine(); + $log->writeStatus(pht('SAVE STATE'), $where); + } + + protected function executeRestoreLocalState() { + $api = $this->getRepositoryAPI(); + + $log = $this->getWorkflow()->getLogEngine(); + + $ref = $this->localRef; + $commit = $this->localCommit; + + if ($ref !== null) { + $where = pht( + 'Restoring local state (to ref "%s" at commit "%s").', + $ref, + $this->getDisplayHash($commit)); + } else { + $where = pht( + 'Restoring local state (to detached commit "%s").', + $this->getDisplayHash($commit)); + } + + $log->writeStatus(pht('LOAD STATE'), $where); + + if ($ref !== null) { + $api->execxLocal('checkout -B %s %s --', $ref, $commit); + + // TODO: We save, but do not restore, the upstream configuration of + // this branch. + + } else { + $api->execxLocal('checkout %s --', $commit); + } + + $api->execxLocal('submodule update --init --recursive'); + } + + protected function executeDiscardLocalState() { + // We don't have anything to clean up in Git. + return; + } + + protected function canStashChanges() { + return true; + } + + protected function getIgnoreHints() { + return array( + pht( + 'To configure Git to ignore certain files in this working copy, '. + 'add the file paths to "%s".', + '.git/info/exclude'), + ); + } + + protected function saveStash() { + $api = $this->getRepositoryAPI(); + + // NOTE: We'd prefer to "git stash create" here, because using "push" + // and "pop" means we're affecting the stash list as a side effect. + + // However, under Git 2.21.1, "git stash create" exits with no output, + // no error, and no effect if the working copy contains only untracked + // files. For now, accept mutations to the stash list. + + $api->execxLocal('stash push --include-untracked --'); + + $log = $this->getWorkflow()->getLogEngine(); + $log->writeStatus( + pht('SAVE STASH'), + pht('Saved uncommitted changes from working copy.')); + + return true; + } + + protected function restoreStash($stash_ref) { + $api = $this->getRepositoryAPI(); + + $log = $this->getWorkflow()->getLogEngine(); + $log->writeStatus( + pht('LOAD STASH'), + pht('Restoring uncommitted changes to working copy.')); + + // NOTE: Under Git 2.21.1, "git stash apply" does not accept "--". + $api->execxLocal('stash apply'); + } + + protected function discardStash($stash_ref) { + $api = $this->getRepositoryAPI(); + + // NOTE: Under Git 2.21.1, "git stash drop" does not accept "--". + $api->execxLocal('stash drop'); + } + + private function getDisplayStashRef($stash_ref) { + return substr($stash_ref, 0, 12); + } + + private function getDisplayHash($hash) { + return substr($hash, 0, 12); + } + +} diff --git a/src/repository/state/ArcanistRepositoryLocalState.php b/src/repository/state/ArcanistRepositoryLocalState.php new file mode 100644 --- /dev/null +++ b/src/repository/state/ArcanistRepositoryLocalState.php @@ -0,0 +1,245 @@ +workflow = $workflow; + return $this; + } + + final public function getWorkflow() { + return $this->workflow; + } + + final public function setRepositoryAPI(ArcanistRepositoryAPI $api) { + $this->repositoryAPI = $api; + return $this; + } + + final public function getRepositoryAPI() { + return $this->repositoryAPI; + } + + final public function saveLocalState() { + $api = $this->getRepositoryAPI(); + + $working_copy_display = tsprintf( + " %s: %s\n", + pht('Working Copy'), + $api->getPath()); + + $conflicts = $api->getMergeConflicts(); + if ($conflicts) { + echo tsprintf( + "\n%!\n%W\n\n%s\n", + pht('MERGE CONFLICTS'), + pht('You have merge conflicts in this working copy.'), + $working_copy_display); + + $lists = array(); + + $lists[] = $this->newDisplayFileList( + pht('Merge conflicts in working copy:'), + $conflicts); + + $this->printFileLists($lists); + + throw new PhutilArgumentUsageException( + pht( + 'Resolve merge conflicts before proceeding.')); + } + + $externals = $api->getDirtyExternalChanges(); + if ($externals) { + $message = pht( + '%s submodule(s) have uncommitted or untracked changes:', + new PhutilNumber(count($externals))); + + $prompt = pht( + 'Ignore the changes to these %s submodule(s) and continue?', + new PhutilNumber(count($externals))); + + $list = id(new PhutilConsoleList()) + ->setWrap(false) + ->addItems($externals); + + id(new PhutilConsoleBlock()) + ->addParagraph($message) + ->addList($list) + ->draw(); + + $ok = phutil_console_confirm($prompt, $default_no = false); + if (!$ok) { + throw new ArcanistUserAbortException(); + } + } + + $uncommitted = $api->getUncommittedChanges(); + $unstaged = $api->getUnstagedChanges(); + $untracked = $api->getUntrackedChanges(); + + // We already dealt with externals. + $unstaged = array_diff($unstaged, $externals); + + // We only want files which are purely uncommitted. + $uncommitted = array_diff($uncommitted, $unstaged); + $uncommitted = array_diff($uncommitted, $externals); + + if ($untracked || $unstaged || $uncommitted) { + echo tsprintf( + "\n%!\n%W\n\n%s\n", + pht('UNCOMMITTED CHANGES'), + pht('You have uncommitted changes in this working copy.'), + $working_copy_display); + + $lists = array(); + + $lists[] = $this->newDisplayFileList( + pht('Untracked changes in working copy:'), + $untracked); + + $lists[] = $this->newDisplayFileList( + pht('Unstaged changes in working copy:'), + $unstaged); + + $lists[] = $this->newDisplayFileList( + pht('Uncommitted changes in working copy:'), + $uncommitted); + + $this->printFileLists($lists); + + if ($untracked) { + $hints = $this->getIgnoreHints(); + foreach ($hints as $hint) { + echo tsprintf("%?\n", $hint); + } + } + + if ($this->canStashChanges()) { + + $query = pht('Stash these changes and continue?'); + + $this->getWorkflow() + ->getPrompt('arc.state.stash') + ->setQuery($query) + ->execute(); + + $stash_ref = $this->saveStash(); + + if ($stash_ref === null) { + throw new Exception( + pht( + 'Expected a non-null return from call to "%s->saveStash()".', + get_class($this))); + } + + $this->stashRef = $stash_ref; + } else { + throw new PhutilArgumentUsageException( + pht( + 'You can not continue with uncommitted changes. Commit or '. + 'discard them before proceeding.')); + } + } + + $this->executeSaveLocalState(); + $this->shouldRestore = true; + + return $this; + } + + final public function restoreLocalState() { + $this->shouldRestore = false; + + $this->executeRestoreLocalState(); + if ($this->stashRef !== null) { + $this->restoreStash($this->stashRef); + } + + return $this; + } + + final public function discardLocalState() { + $this->shouldRestore = false; + + $this->executeDiscardLocalState(); + if ($this->stashRef !== null) { + $this->restoreStash($this->stashRef); + $this->discardStash($this->stashRef); + $this->stashRef = null; + } + + return $this; + } + + final public function __destruct() { + if ($this->shouldRestore) { + $this->restoreLocalState(); + } + + $this->discardLocalState(); + } + + protected function canStashChanges() { + return false; + } + + protected function saveStash() { + throw new PhutilMethodNotImplementedException(); + } + + protected function restoreStash($ref) { + throw new PhutilMethodNotImplementedException(); + } + + protected function discardStash($ref) { + throw new PhutilMethodNotImplementedException(); + } + + abstract protected function executeSaveLocalState(); + abstract protected function executeRestoreLocalState(); + abstract protected function executeDiscardLocalState(); + + protected function getIgnoreHints() { + return array(); + } + + final protected function newDisplayFileList($title, array $files) { + if (!$files) { + return null; + } + + $items = array(); + $items[] = tsprintf("%s\n\n", $title); + foreach ($files as $file) { + $items[] = tsprintf( + " %s\n", + $file); + } + + return $items; + } + + final protected function printFileLists(array $lists) { + $lists = array_filter($lists); + + $last_key = last_key($lists); + foreach ($lists as $key => $list) { + foreach ($list as $item) { + echo tsprintf('%B', $item); + } + if ($key !== $last_key) { + echo tsprintf("\n\n"); + } + } + + echo tsprintf("\n"); + } + +}