diff --git a/src/future/Future.php b/src/future/Future.php index dcc3c25f..34740a5b 100644 --- a/src/future/Future.php +++ b/src/future/Future.php @@ -1,189 +1,184 @@ getDefaultWait(); + public function resolve() { + $args = func_get_args(); + if (count($args)) { + throw new Exception( + pht( + 'Parameter "timeout" to "Future->resolve()" is no longer '. + 'supported. Update the caller so it no longer passes a '. + 'timeout.')); + } + + $wait = $this->getDefaultWait(); do { $this->checkException(); if ($this->isReady()) { break; } $read = $this->getReadSockets(); $write = $this->getWriteSockets(); - if ($timeout !== null) { - $elapsed = microtime(true) - $start; - - if ($elapsed > $timeout) { - $this->checkException(); - return null; - } else { - $wait = $timeout - $elapsed; - } - } - if ($read || $write) { self::waitForSockets($read, $write, $wait); } } while (true); $this->checkException(); return $this->getResult(); } public function setException(Exception $ex) { $this->exception = $ex; return $this; } public function getException() { return $this->exception; } /** * If an exception was set by setException(), throw it. */ private function checkException() { if ($this->exception) { throw $this->exception; } } /** * Retrieve a list of sockets which we can wait to become readable while * a future is resolving. If your future has sockets which can be * `select()`ed, return them here (or in @{method:getWriteSockets}) to make * the resolve loop do a `select()`. If you do not return sockets in either * case, you'll get a busy wait. * * @return list A list of sockets which we expect to become readable. */ public function getReadSockets() { return array(); } /** * Retrieve a list of sockets which we can wait to become writable while a * future is resolving. See @{method:getReadSockets}. * * @return list A list of sockets which we expect to become writable. */ public function getWriteSockets() { return array(); } /** * Wait for activity on one of several sockets. * * @param list List of sockets expected to become readable. * @param list List of sockets expected to become writable. * @param float Timeout, in seconds. * @return void */ public static function waitForSockets( array $read_list, array $write_list, $timeout = 1) { if (!self::$handlerInstalled) { // If we're spawning child processes, we need to install a signal handler // here to catch cases like execing '(sleep 60 &) &' where the child // exits but a socket is kept open. But we don't actually need to do // anything because the SIGCHLD will interrupt the stream_select(), as // long as we have a handler registered. if (function_exists('pcntl_signal')) { if (!pcntl_signal(SIGCHLD, array(__CLASS__, 'handleSIGCHLD'))) { throw new Exception(pht('Failed to install signal handler!')); } } self::$handlerInstalled = true; } $timeout_sec = (int)$timeout; $timeout_usec = (int)(1000000 * ($timeout - $timeout_sec)); $exceptfds = array(); $ok = @stream_select( $read_list, $write_list, $exceptfds, $timeout_sec, $timeout_usec); if ($ok === false) { // Hopefully, means we received a SIGCHLD. In the worst case, we degrade // to a busy wait. } } public static function handleSIGCHLD($signo) { // This function is a dummy, we just need to have some handler registered // so that PHP will get interrupted during stream_select(). If we don't // register a handler, stream_select() won't fail. } /** * Retrieve the final result of the future. This method will be called after * the future is ready (as per @{method:isReady}) but before results are * passed back to the caller. The major use of this function is that you can * override it in subclasses to do postprocessing or error checking, which is * particularly useful if building application-specific futures on top of * primitive transport futures (like @{class:CurlFuture} and * @{class:ExecFuture}) which can make it tricky to hook this logic into the * main pipeline. * * @return mixed Final resolution of this future. */ protected function getResult() { return $this->result; } /** * Default amount of time to wait on stream select for this future. Normally * 1 second is fine, but if the future has a timeout sooner than that it * should return the amount of time left before the timeout. */ public function getDefaultWait() { return 1; } public function start() { $this->isReady(); return $this; } } diff --git a/src/future/FutureProxy.php b/src/future/FutureProxy.php index 0cb6fda9..37b39084 100644 --- a/src/future/FutureProxy.php +++ b/src/future/FutureProxy.php @@ -1,71 +1,71 @@ setProxiedFuture($proxied); } } public function setProxiedFuture(Future $proxied) { $this->proxied = $proxied; return $this; } protected function getProxiedFuture() { if (!$this->proxied) { throw new Exception(pht('The proxied future has not been provided yet.')); } return $this->proxied; } public function isReady() { return $this->getProxiedFuture()->isReady(); } - public function resolve($timeout = null) { - $this->getProxiedFuture()->resolve($timeout); + public function resolve() { + $this->getProxiedFuture()->resolve(); return $this->getResult(); } public function setException(Exception $ex) { $this->getProxiedFuture()->setException($ex); return $this; } public function getException() { return $this->getProxiedFuture()->getException(); } public function getReadSockets() { return $this->getProxiedFuture()->getReadSockets(); } public function getWriteSockets() { return $this->getProxiedFuture()->getWriteSockets(); } protected function getResult() { if ($this->result === null) { $result = $this->getProxiedFuture()->resolve(); $result = $this->didReceiveResult($result); $this->result = $result; } return $this->result; } public function start() { $this->getProxiedFuture()->start(); return $this; } abstract protected function didReceiveResult($result); } diff --git a/src/future/exec/ExecFuture.php b/src/future/exec/ExecFuture.php index f3558e18..54b8d60f 100644 --- a/src/future/exec/ExecFuture.php +++ b/src/future/exec/ExecFuture.php @@ -1,956 +1,948 @@ array('pipe', 'r'), // stdin 1 => array('pipe', 'w'), // stdout 2 => array('pipe', 'w'), // stderr ); protected function didConstruct() { $this->stdin = new PhutilRope(); } /* -( Command Information )------------------------------------------------ */ /** * Retrieve the byte limit for the stderr buffer. * * @return int Maximum buffer size, in bytes. * @task info */ public function getStderrSizeLimit() { return $this->stderrSizeLimit; } /** * Retrieve the byte limit for the stdout buffer. * * @return int Maximum buffer size, in bytes. * @task info */ public function getStdoutSizeLimit() { return $this->stdoutSizeLimit; } /** * Get the process's pid. This only works after execution is initiated, e.g. * by a call to start(). * * @return int Process ID of the executing process. * @task info */ public function getPID() { $status = $this->procGetStatus(); return $status['pid']; } /* -( Configuring Execution )---------------------------------------------- */ /** * Set a maximum size for the stdout read buffer. To limit stderr, see * @{method:setStderrSizeLimit}. The major use of these methods is to use less * memory if you are running a command which sometimes produces huge volumes * of output that you don't really care about. * * NOTE: Setting this to 0 means "no buffer", not "unlimited buffer". * * @param int Maximum size of the stdout read buffer. * @return this * @task config */ public function setStdoutSizeLimit($limit) { $this->stdoutSizeLimit = $limit; return $this; } /** * Set a maximum size for the stderr read buffer. * See @{method:setStdoutSizeLimit} for discussion. * * @param int Maximum size of the stderr read buffer. * @return this * @task config */ public function setStderrSizeLimit($limit) { $this->stderrSizeLimit = $limit; return $this; } /** * Set the maximum internal read buffer size this future. The future will * block reads once the internal stdout or stderr buffer exceeds this size. * * NOTE: If you @{method:resolve} a future with a read buffer limit, you may * block forever! * * TODO: We should probably release the read buffer limit during * @{method:resolve}, or otherwise detect this. For now, be careful. * * @param int|null Maximum buffer size, or `null` for unlimited. * @return this */ public function setReadBufferSize($read_buffer_size) { $this->readBufferSize = $read_buffer_size; return $this; } /* -( Interacting With Commands )------------------------------------------ */ /** * Read and return output from stdout and stderr, if any is available. This * method keeps a read cursor on each stream, but the entire streams are * still returned when the future resolves. You can call read() again after * resolving the future to retrieve only the parts of the streams you did not * previously read: * * $future = new ExecFuture('...'); * // ... * list($stdout) = $future->read(); // Returns output so far * list($stdout) = $future->read(); // Returns new output since first call * // ... * list($stdout) = $future->resolvex(); // Returns ALL output * list($stdout) = $future->read(); // Returns unread output * * NOTE: If you set a limit with @{method:setStdoutSizeLimit} or * @{method:setStderrSizeLimit}, this method will not be able to read data * past the limit. * * NOTE: If you call @{method:discardBuffers}, all the stdout/stderr data * will be thrown away and the cursors will be reset. * * @return pair <$stdout, $stderr> pair with new output since the last call * to this method. * @task interact */ public function read() { $stdout = $this->readStdout(); $result = array( $stdout, (string)substr($this->stderr, $this->stderrPos), ); $this->stderrPos = strlen($this->stderr); return $result; } public function readStdout() { if ($this->start) { $this->isReady(); // Sync } $result = (string)substr($this->stdout, $this->stdoutPos); $this->stdoutPos = strlen($this->stdout); return $result; } /** * Write data to stdin of the command. * * @param string Data to write. * @param bool If true, keep the pipe open for writing. By default, the pipe * will be closed as soon as possible so that commands which * listen for EOF will execute. If you want to keep the pipe open * past the start of command execution, do an empty write with * `$keep_pipe = true` first. * @return this * @task interact */ public function write($data, $keep_pipe = false) { if (strlen($data)) { if (!$this->stdin) { throw new Exception(pht('Writing to a closed pipe!')); } $this->stdin->append($data); } $this->closePipe = !$keep_pipe; return $this; } /** * Permanently discard the stdout and stderr buffers and reset the read * cursors. This is basically useful only if you are streaming a large amount - * of data from some process: - * - * $future = new ExecFuture('zcat huge_file.gz'); - * do { - * $done = $future->resolve(0.1); // Every 100ms, - * list($stdout) = $future->read(); // read output... - * echo $stdout; // send it somewhere... - * $future->discardBuffers(); // and then free the buffers. - * } while ($done === null); + * of data from some process. * * Conceivably you might also need to do this if you're writing a client using * @{class:ExecFuture} and `netcat`, but you probably should not do that. * * NOTE: This completely discards the data. It won't be available when the * future resolves. This is almost certainly only useful if you need the * buffer memory for some reason. * * @return this * @task interact */ public function discardBuffers() { $this->discardStdoutBuffer(); $this->stderr = ''; $this->stderrPos = 0; return $this; } public function discardStdoutBuffer() { $this->stdout = ''; $this->stdoutPos = 0; return $this; } /** * Returns true if this future was killed by a timeout configured with * @{method:setTimeout}. * * @return bool True if the future was killed for exceeding its time limit. */ public function getWasKilledByTimeout() { return $this->killedByTimeout; } /* -( Configuring Execution )---------------------------------------------- */ /** * Set a hard limit on execution time. If the command runs longer, it will * be terminated and the future will resolve with an error code. You can test * if a future was killed by a timeout with @{method:getWasKilledByTimeout}. * * The subprocess will be sent a `TERM` signal, and then a `KILL` signal a * short while later if it fails to exit. * * @param int Maximum number of seconds this command may execute for before * it is signaled. * @return this * @task config */ public function setTimeout($seconds) { $this->terminateTimeout = $seconds; $this->killTimeout = $seconds + min($seconds, 60); return $this; } /* -( Resolving Execution )------------------------------------------------ */ /** * Resolve a command you expect to exit with return code 0. Works like * @{method:resolve}, but throws if $err is nonempty. Returns only * $stdout and $stderr. See also @{function:execx}. * * list($stdout, $stderr) = $future->resolvex(); * * @param float Optional timeout after which resolution will pause and * execution will return to the caller. * @return pair <$stdout, $stderr> pair. * @task resolve */ - public function resolvex($timeout = null) { - list($err, $stdout, $stderr) = $this->resolve($timeout); + public function resolvex() { + list($err, $stdout, $stderr) = $this->resolve(); if ($err) { $cmd = $this->getCommand(); if ($this->getWasKilledByTimeout()) { // NOTE: The timeout can be a float and PhutilNumber only handles // integers, so just use "%s" to render it. $message = pht( 'Command killed by timeout after running for more than %s seconds.', $this->terminateTimeout); } else { $message = pht('Command failed with error #%d!', $err); } throw new CommandException( $message, $cmd, $err, $stdout, $stderr); } return array($stdout, $stderr); } /** * Resolve a command you expect to return valid JSON. Works like * @{method:resolvex}, but also throws if stderr is nonempty, or stdout is not * valid JSON. Returns a PHP array, decoded from the JSON command output. * * @param float Optional timeout after which resolution will pause and * execution will return to the caller. * @return array PHP array, decoded from JSON command output. * @task resolve */ - public function resolveJSON($timeout = null) { - list($stdout, $stderr) = $this->resolvex($timeout); + public function resolveJSON() { + list($stdout, $stderr) = $this->resolvex(); if (strlen($stderr)) { $cmd = $this->getCommand(); throw new CommandException( pht( "JSON command '%s' emitted text to stderr when none was expected: %d", $cmd, $stderr), $cmd, 0, $stdout, $stderr); } try { return phutil_json_decode($stdout); } catch (PhutilJSONParserException $ex) { $cmd = $this->getCommand(); throw new CommandException( pht( "JSON command '%s' did not produce a valid JSON object on stdout: %s", $cmd, $stdout), $cmd, 0, $stdout, $stderr); } } /** * Resolve the process by abruptly terminating it. * * @return list List of results. * @task resolve */ public function resolveKill() { if (!$this->result) { $signal = 9; if ($this->proc) { proc_terminate($this->proc, $signal); } $this->result = array( 128 + $signal, $this->stdout, $this->stderr, ); $this->closeProcess(); } return $this->result; } /* -( Internals )---------------------------------------------------------- */ /** * Provides read sockets to the future core. * * @return list List of read sockets. * @task internal */ public function getReadSockets() { list($stdin, $stdout, $stderr) = $this->pipes; $sockets = array(); if (isset($stdout) && !feof($stdout)) { $sockets[] = $stdout; } if (isset($stderr) && !feof($stderr)) { $sockets[] = $stderr; } return $sockets; } /** * Provides write sockets to the future core. * * @return list List of write sockets. * @task internal */ public function getWriteSockets() { list($stdin, $stdout, $stderr) = $this->pipes; $sockets = array(); if (isset($stdin) && $this->stdin->getByteLength() && !feof($stdin)) { $sockets[] = $stdin; } return $sockets; } /** * Determine if the read buffer is empty. * * @return bool True if the read buffer is empty. * @task internal */ public function isReadBufferEmpty() { return !strlen($this->stdout); } /** * Determine if the write buffer is empty. * * @return bool True if the write buffer is empty. * @task internal */ public function isWriteBufferEmpty() { return !$this->getWriteBufferSize(); } /** * Determine the number of bytes in the write buffer. * * @return int Number of bytes in the write buffer. * @task internal */ public function getWriteBufferSize() { if (!$this->stdin) { return 0; } return $this->stdin->getByteLength(); } /** * Reads some bytes from a stream, discarding output once a certain amount * has been accumulated. * * @param resource Stream to read from. * @param int Maximum number of bytes to return from $stream. If * additional bytes are available, they will be read and * discarded. * @param string Human-readable description of stream, for exception * message. * @param int Maximum number of bytes to read. * @return string The data read from the stream. * @task internal */ private function readAndDiscard($stream, $limit, $description, $length) { $output = ''; if ($length <= 0) { return ''; } do { $data = fread($stream, min($length, 64 * 1024)); if (false === $data) { throw new Exception(pht('Failed to read from %s', $description)); } $read_bytes = strlen($data); if ($read_bytes > 0 && $limit > 0) { if ($read_bytes > $limit) { $data = substr($data, 0, $limit); } $output .= $data; $limit -= strlen($data); } if (strlen($output) >= $length) { break; } } while ($read_bytes > 0); return $output; } /** * Begin or continue command execution. * * @return bool True if future has resolved. * @task internal */ public function isReady() { // NOTE: We have soft dependencies on PhutilServiceProfiler and // PhutilErrorTrap here. These dependencies are soft to avoid the need to // build them into the Phage agent. Under normal circumstances, these // classes are always available. if (!$this->pipes) { $is_windows = phutil_is_windows(); // NOTE: See note above about Phage. if (class_exists('PhutilServiceProfiler')) { $profiler = PhutilServiceProfiler::getInstance(); $this->profilerCallID = $profiler->beginServiceCall( array( 'type' => 'exec', 'command' => phutil_string_cast($this->getCommand()), )); } if (!$this->start) { // We might already have started the timer via initiating resolution. $this->start = microtime(true); } $unmasked_command = $this->getCommand(); $unmasked_command = $unmasked_command->getUnmaskedString(); $pipes = array(); if ($this->hasEnv()) { $env = $this->getEnv(); } else { $env = null; } $cwd = $this->getCWD(); // NOTE: See note above about Phage. if (class_exists('PhutilErrorTrap')) { $trap = new PhutilErrorTrap(); } else { $trap = null; } $spec = self::$descriptorSpec; if ($is_windows) { $stdout_file = new TempFile(); $stderr_file = new TempFile(); $stdout_handle = fopen($stdout_file, 'wb'); if (!$stdout_handle) { throw new Exception( pht( 'Unable to open stdout temporary file ("%s") for writing.', $stdout_file)); } $stderr_handle = fopen($stderr_file, 'wb'); if (!$stderr_handle) { throw new Exception( pht( 'Unable to open stderr temporary file ("%s") for writing.', $stderr_file)); } $spec = array( 0 => self::$descriptorSpec[0], 1 => $stdout_handle, 2 => $stderr_handle, ); } $proc = @proc_open( $unmasked_command, $spec, $pipes, $cwd, $env, array( 'bypass_shell' => true, )); if ($trap) { $err = $trap->getErrorsAsString(); $trap->destroy(); } else { $err = error_get_last(); } if ($is_windows) { fclose($stdout_handle); fclose($stderr_handle); } if (!is_resource($proc)) { // When you run an invalid command on a Linux system, the "proc_open()" // works and then the process (really a "/bin/sh -c ...") exits after // it fails to resolve the command. // When you run an invalid command on a Windows system, we bypass the // shell and the "proc_open()" itself fails. Throw a "CommandException" // here for consistency with the Linux behavior in this common failure // case. throw new CommandException( pht( 'Call to "proc_open()" to open a subprocess failed: %s', $err), $this->getCommand(), 1, '', ''); } if ($is_windows) { $stdout_handle = fopen($stdout_file, 'rb'); if (!$stdout_handle) { throw new Exception( pht( 'Unable to open stdout temporary file ("%s") for reading.', $stdout_file)); } $stderr_handle = fopen($stderr_file, 'rb'); if (!$stderr_handle) { throw new Exception( pht( 'Unable to open stderr temporary file ("%s") for reading.', $stderr_file)); } $pipes = array( 0 => $pipes[0], 1 => $stdout_handle, 2 => $stderr_handle, ); $this->windowsStdoutTempFile = $stdout_file; $this->windowsStderrTempFile = $stderr_file; } $this->pipes = $pipes; $this->proc = $proc; list($stdin, $stdout, $stderr) = $pipes; if (!$is_windows) { // On Windows, we redirect process standard output and standard error // through temporary files. Files don't block, so we don't need to make // these streams nonblocking. if ((!stream_set_blocking($stdout, false)) || (!stream_set_blocking($stderr, false)) || (!stream_set_blocking($stdin, false))) { $this->__destruct(); throw new Exception(pht('Failed to set streams nonblocking.')); } } $this->tryToCloseStdin(); return false; } if (!$this->proc) { return true; } list($stdin, $stdout, $stderr) = $this->pipes; while (isset($this->stdin) && $this->stdin->getByteLength()) { $write_segment = $this->stdin->getAnyPrefix(); $bytes = fwrite($stdin, $write_segment); if ($bytes === false) { throw new Exception(pht('Unable to write to stdin!')); } else if ($bytes) { $this->stdin->removeBytesFromHead($bytes); } else { // Writes are blocked for now. break; } } $this->tryToCloseStdin(); // Read status before reading pipes so that we can never miss data that // arrives between our last read and the process exiting. $status = $this->procGetStatus(); $read_buffer_size = $this->readBufferSize; $max_stdout_read_bytes = PHP_INT_MAX; $max_stderr_read_bytes = PHP_INT_MAX; if ($read_buffer_size !== null) { $max_stdout_read_bytes = $read_buffer_size - strlen($this->stdout); $max_stderr_read_bytes = $read_buffer_size - strlen($this->stderr); } if ($max_stdout_read_bytes > 0) { $this->stdout .= $this->readAndDiscard( $stdout, $this->getStdoutSizeLimit() - strlen($this->stdout), 'stdout', $max_stdout_read_bytes); } if ($max_stderr_read_bytes > 0) { $this->stderr .= $this->readAndDiscard( $stderr, $this->getStderrSizeLimit() - strlen($this->stderr), 'stderr', $max_stderr_read_bytes); } $is_done = false; if (!$status['running']) { // We may still have unread bytes on stdout or stderr, particularly if // this future is being buffered and streamed. If we do, we don't want to // consider the subprocess to have exited until we've read everything. // See T9724 for context. if (feof($stdout) && feof($stderr)) { $is_done = true; } } if ($is_done) { // If the subprocess got nuked with `kill -9`, we get a -1 exitcode. // Upgrade this to a slightly more informative value by examining the // terminating signal code. $err = $status['exitcode']; if ($err == -1) { if ($status['signaled']) { $err = 128 + $status['termsig']; } } $this->result = array( $err, $this->stdout, $this->stderr, ); $this->closeProcess(); return true; } $elapsed = (microtime(true) - $this->start); if ($this->terminateTimeout && ($elapsed >= $this->terminateTimeout)) { if (!$this->didTerminate) { $this->killedByTimeout = true; $this->sendTerminateSignal(); return false; } } if ($this->killTimeout && ($elapsed >= $this->killTimeout)) { $this->killedByTimeout = true; $this->resolveKill(); return true; } } /** * @return void * @task internal */ public function __destruct() { if (!$this->proc) { return; } // NOTE: If we try to proc_close() an open process, we hang indefinitely. To // avoid this, kill the process explicitly if it's still running. $status = $this->procGetStatus(); if ($status['running']) { $this->sendTerminateSignal(); if (!$this->waitForExit(5)) { $this->resolveKill(); } } else { $this->closeProcess(); } } /** * Close and free resources if necessary. * * @return void * @task internal */ private function closeProcess() { foreach ($this->pipes as $pipe) { if (isset($pipe)) { @fclose($pipe); } } $this->pipes = array(null, null, null); if ($this->proc) { @proc_close($this->proc); $this->proc = null; } $this->stdin = null; unset($this->windowsStdoutTempFile); unset($this->windowsStderrTempFile); if ($this->profilerCallID !== null) { $profiler = PhutilServiceProfiler::getInstance(); $profiler->endServiceCall( $this->profilerCallID, array( 'err' => $this->result ? idx($this->result, 0) : null, )); $this->profilerCallID = null; } } /** * Execute `proc_get_status()`, but avoid pitfalls. * * @return dict Process status. * @task internal */ private function procGetStatus() { // After the process exits, we only get one chance to read proc_get_status() // before it starts returning garbage. Make sure we don't throw away the // last good read. if ($this->procStatus) { if (!$this->procStatus['running']) { return $this->procStatus; } } $this->procStatus = proc_get_status($this->proc); return $this->procStatus; } /** * Try to close stdin, if we're done using it. This keeps us from hanging if * the process on the other end of the pipe is waiting for EOF. * * @return void * @task internal */ private function tryToCloseStdin() { if (!$this->closePipe) { // We've been told to keep the pipe open by a call to write(..., true). return; } if ($this->stdin->getByteLength()) { // We still have bytes to write. return; } list($stdin) = $this->pipes; if (!$stdin) { // We've already closed stdin. return; } // There's nothing stopping us from closing stdin, so close it. @fclose($stdin); $this->pipes[0] = null; } public function getDefaultWait() { $wait = parent::getDefaultWait(); $next_timeout = $this->getNextTimeout(); if ($next_timeout) { if (!$this->start) { $this->start = microtime(true); } $elapsed = (microtime(true) - $this->start); $wait = max(0, min($next_timeout - $elapsed, $wait)); } return $wait; } private function getNextTimeout() { if ($this->didTerminate) { return $this->killTimeout; } else { return $this->terminateTimeout; } } private function sendTerminateSignal() { $this->didTerminate = true; proc_terminate($this->proc); return $this; } private function waitForExit($duration) { $start = microtime(true); while (true) { $status = $this->procGetStatus(); if (!$status['running']) { return true; } $waited = (microtime(true) - $start); if ($waited > $duration) { return false; } } } } diff --git a/src/future/exec/__tests__/ExecFutureTestCase.php b/src/future/exec/__tests__/ExecFutureTestCase.php index 25c5e46a..e10a188a 100644 --- a/src/future/exec/__tests__/ExecFutureTestCase.php +++ b/src/future/exec/__tests__/ExecFutureTestCase.php @@ -1,168 +1,173 @@ newCat() ->write('') ->resolvex(); $this->assertEqual('', $stdout); } private function newCat() { $bin = $this->getSupportExecutable('cat'); return new ExecFuture('php -f %R', $bin); } private function newSleep($duration) { $bin = $this->getSupportExecutable('sleep'); return new ExecFuture('php -f %R -- %s', $bin, $duration); } public function testKeepPipe() { // NOTE: This is mostly testing the semantics of $keep_pipe in write(). list($stdout) = $this->newCat() ->write('', true) ->start() ->write('x', true) ->write('y', true) ->write('z', false) ->resolvex(); $this->assertEqual('xyz', $stdout); } public function testLargeBuffer() { // NOTE: This is mostly a coverage test to hit branches where we're still // flushing a buffer. $data = str_repeat('x', 1024 * 1024 * 4); list($stdout) = $this->newCat()->write($data)->resolvex(); $this->assertEqual($data, $stdout); } public function testBufferLimit() { $data = str_repeat('x', 1024 * 1024); list($stdout) = $this->newCat() ->setStdoutSizeLimit(1024) ->write($data) ->resolvex(); $this->assertEqual(substr($data, 0, 1024), $stdout); } public function testResolveTimeoutTestShouldRunLessThan1Sec() { // NOTE: This tests interactions between the resolve() timeout and the - // ExecFuture timeout, which are similar but not identical. + // resolution timeout, which are somewhat similar but not identical. $future = $this->newSleep(32000)->start(); $future->setTimeout(32000); // We expect this to return in 0.01s. - $result = $future->resolve(0.01); + $iterator = (new FutureIterator(array($future))) + ->setUpdateInterval(0.01); + foreach ($iterator as $resolved_result) { + $result = $resolved_result; + break; + } $this->assertEqual($result, null); // We expect this to now force the time out / kill immediately. If we don't // do this, we'll hang when exiting until our subprocess exits (32000 // seconds!) $future->setTimeout(0.01); $future->resolve(); } public function testTerminateWithoutStart() { // We never start this future, but it should be fine to kill a future from // any state. $future = $this->newSleep(1); $future->resolveKill(); $this->assertTrue(true); } public function testTimeoutTestShouldRunLessThan1Sec() { // NOTE: This is partly testing that we choose appropriate select wait // times; this test should run for significantly less than 1 second. $future = $this->newSleep(32000); list($err) = $future->setTimeout(0.01)->resolve(); $this->assertTrue($err > 0); $this->assertTrue($future->getWasKilledByTimeout()); } public function testMultipleTimeoutsTestShouldRunLessThan1Sec() { $futures = array(); for ($ii = 0; $ii < 4; $ii++) { $futures[] = $this->newSleep(32000)->setTimeout(0.01); } foreach (new FutureIterator($futures) as $future) { list($err) = $future->resolve(); $this->assertTrue($err > 0); $this->assertTrue($future->getWasKilledByTimeout()); } } public function testMultipleResolves() { // It should be safe to call resolve(), resolvex(), resolveKill(), etc., // as many times as you want on the same process. $bin = $this->getSupportExecutable('echo'); $future = new ExecFuture('php -f %R -- quack', $bin); $future->resolve(); $future->resolvex(); list($err) = $future->resolveKill(); $this->assertEqual(0, $err); } public function testReadBuffering() { $str_len_8 = 'abcdefgh'; $str_len_4 = 'abcd'; // This is a write/read with no read buffer. $future = $this->newCat(); $future->write($str_len_8); do { $future->isReady(); list($read) = $future->read(); if (strlen($read)) { break; } } while (true); // We expect to get the entire string back in the read. $this->assertEqual($str_len_8, $read); $future->resolve(); // This is a write/read with a read buffer. $future = $this->newCat(); $future->write($str_len_8); // Set the read buffer size. $future->setReadBufferSize(4); do { $future->isReady(); list($read) = $future->read(); if (strlen($read)) { break; } } while (true); // We expect to get the entire string back in the read. $this->assertEqual($str_len_4, $read); // Remove the limit so we can resolve the future. $future->setReadBufferSize(null); $future->resolve(); } } diff --git a/src/workflow/ArcanistWorkflow.php b/src/workflow/ArcanistWorkflow.php index f4a8d8b4..7fc3db1a 100644 --- a/src/workflow/ArcanistWorkflow.php +++ b/src/workflow/ArcanistWorkflow.php @@ -1,2252 +1,2252 @@ toolset = $toolset; return $this; } final public function getToolset() { return $this->toolset; } final public function setRuntime(ArcanistRuntime $runtime) { $this->runtime = $runtime; return $this; } final public function getRuntime() { return $this->runtime; } final public function setConfigurationEngine( ArcanistConfigurationEngine $engine) { $this->configurationEngine = $engine; return $this; } final public function getConfigurationEngine() { return $this->configurationEngine; } final public function setConfigurationSourceList( ArcanistConfigurationSourceList $list) { $this->configurationSourceList = $list; return $this; } final public function getConfigurationSourceList() { return $this->configurationSourceList; } public function newPhutilWorkflow() { $arguments = $this->getWorkflowArguments(); assert_instances_of($arguments, 'ArcanistWorkflowArgument'); $specs = mpull($arguments, 'getPhutilSpecification'); $phutil_workflow = id(new ArcanistPhutilWorkflow()) ->setName($this->getWorkflowName()) ->setWorkflow($this) ->setArguments($specs); $information = $this->getWorkflowInformation(); if ($information) { $synopsis = $information->getSynopsis(); if (strlen($synopsis)) { $phutil_workflow->setSynopsis($synopsis); } $examples = $information->getExamples(); if ($examples) { $examples = implode("\n", $examples); $phutil_workflow->setExamples($examples); } $help = $information->getHelp(); if (strlen($help)) { // Unwrap linebreaks in the help text so we don't get weird formatting. $help = preg_replace("/(?<=\S)\n(?=\S)/", ' ', $help); $phutil_workflow->setHelp($help); } } return $phutil_workflow; } final protected function newWorkflowArgument($key) { return id(new ArcanistWorkflowArgument()) ->setKey($key); } final protected function newWorkflowInformation() { return new ArcanistWorkflowInformation(); } final public function executeWorkflow(PhutilArgumentParser $args) { $runtime = $this->getRuntime(); $this->arguments = $args; $caught = null; $runtime->pushWorkflow($this); try { $err = $this->runWorkflow($args); } catch (Exception $ex) { $caught = $ex; } try { $this->runWorkflowCleanup(); } catch (Exception $ex) { phlog($ex); } $runtime->popWorkflow(); if ($caught) { throw $caught; } return $err; } final protected function getLogEngine() { return $this->getRuntime()->getLogEngine(); } protected function runWorkflowCleanup() { // TOOLSETS: Do we need this? return; } public function __construct() {} public function run() { throw new PhutilMethodNotImplementedException(); } /** * Finalizes any cleanup operations that need to occur regardless of * whether the command succeeded or failed. */ public function finalize() { $this->finalizeWorkingCopy(); } /** * Return the command used to invoke this workflow from the command like, * e.g. "help" for @{class:ArcanistHelpWorkflow}. * * @return string The command a user types to invoke this workflow. */ abstract public function getWorkflowName(); /** * Return console formatted string with all command synopses. * * @return string 6-space indented list of available command synopses. */ public function getCommandSynopses() { return array(); } /** * Return console formatted string with command help printed in `arc help`. * * @return string 10-space indented help to use the command. */ public function getCommandHelp() { return null; } public function supportsToolset(ArcanistToolset $toolset) { return false; } /* -( Conduit )------------------------------------------------------------ */ /** * Set the URI which the workflow will open a conduit connection to when * @{method:establishConduit} is called. Arcanist makes an effort to set * this by default for all workflows (by reading ##.arcconfig## and/or the * value of ##--conduit-uri##) even if they don't need Conduit, so a workflow * can generally upgrade into a conduit workflow later by just calling * @{method:establishConduit}. * * You generally should not need to call this method unless you are * specifically overriding the default URI. It is normally sufficient to * just invoke @{method:establishConduit}. * * NOTE: You can not call this after a conduit has been established. * * @param string The URI to open a conduit to when @{method:establishConduit} * is called. * @return this * @task conduit */ final public function setConduitURI($conduit_uri) { if ($this->conduit) { throw new Exception( pht( 'You can not change the Conduit URI after a '. 'conduit is already open.')); } $this->conduitURI = $conduit_uri; return $this; } /** * Returns the URI the conduit connection within the workflow uses. * * @return string * @task conduit */ final public function getConduitURI() { return $this->conduitURI; } /** * Open a conduit channel to the server which was previously configured by * calling @{method:setConduitURI}. Arcanist will do this automatically if * the workflow returns ##true## from @{method:requiresConduit}, or you can * later upgrade a workflow and build a conduit by invoking it manually. * * You must establish a conduit before you can make conduit calls. * * NOTE: You must call @{method:setConduitURI} before you can call this * method. * * @return this * @task conduit */ final public function establishConduit() { if ($this->conduit) { return $this; } if (!$this->conduitURI) { throw new Exception( pht( 'You must specify a Conduit URI with %s before you can '. 'establish a conduit.', 'setConduitURI()')); } $this->conduit = new ConduitClient($this->conduitURI); if ($this->conduitTimeout) { $this->conduit->setTimeout($this->conduitTimeout); } return $this; } final public function getConfigFromAnySource($key) { return $this->configurationManager->getConfigFromAnySource($key); } /** * Set credentials which will be used to authenticate against Conduit. These * credentials can then be used to establish an authenticated connection to * conduit by calling @{method:authenticateConduit}. Arcanist sets some * defaults for all workflows regardless of whether or not they return true * from @{method:requireAuthentication}, based on the ##~/.arcrc## and * ##.arcconf## files if they are present. Thus, you can generally upgrade a * workflow which does not require authentication into an authenticated * workflow by later invoking @{method:requireAuthentication}. You should not * normally need to call this method unless you are specifically overriding * the defaults. * * NOTE: You can not call this method after calling * @{method:authenticateConduit}. * * @param dict A credential dictionary, see @{method:authenticateConduit}. * @return this * @task conduit */ final public function setConduitCredentials(array $credentials) { if ($this->isConduitAuthenticated()) { throw new Exception( pht('You may not set new credentials after authenticating conduit.')); } $this->conduitCredentials = $credentials; return $this; } /** * Get the protocol version the client should identify with. * * @return int Version the client should claim to be. * @task conduit */ final public function getConduitVersion() { return 6; } /** * Open and authenticate a conduit connection to a Phabricator server using * provided credentials. Normally, Arcanist does this for you automatically * when you return true from @{method:requiresAuthentication}, but you can * also upgrade an existing workflow to one with an authenticated conduit * by invoking this method manually. * * You must authenticate the conduit before you can make authenticated conduit * calls (almost all calls require authentication). * * This method uses credentials provided via @{method:setConduitCredentials} * to authenticate to the server: * * - ##user## (required) The username to authenticate with. * - ##certificate## (required) The Conduit certificate to use. * - ##description## (optional) Description of the invoking command. * * Successful authentication allows you to call @{method:getUserPHID} and * @{method:getUserName}, as well as use the client you access with * @{method:getConduit} to make authenticated calls. * * NOTE: You must call @{method:setConduitURI} and * @{method:setConduitCredentials} before you invoke this method. * * @return this * @task conduit */ final public function authenticateConduit() { if ($this->isConduitAuthenticated()) { return $this; } $this->establishConduit(); $credentials = $this->conduitCredentials; try { if (!$credentials) { throw new Exception( pht( 'Set conduit credentials with %s before authenticating conduit!', 'setConduitCredentials()')); } // If we have `token`, this server supports the simpler, new-style // token-based authentication. Use that instead of all the certificate // stuff. $token = idx($credentials, 'token'); if (strlen($token)) { $conduit = $this->getConduit(); $conduit->setConduitToken($token); try { $result = $this->getConduit()->callMethodSynchronous( 'user.whoami', array()); $this->userName = $result['userName']; $this->userPHID = $result['phid']; $this->conduitAuthenticated = true; return $this; } catch (Exception $ex) { $conduit->setConduitToken(null); throw $ex; } } if (empty($credentials['user'])) { throw new ConduitClientException( 'ERR-INVALID-USER', pht('Empty user in credentials.')); } if (empty($credentials['certificate'])) { throw new ConduitClientException( 'ERR-NO-CERTIFICATE', pht('Empty certificate in credentials.')); } $description = idx($credentials, 'description', ''); $user = $credentials['user']; $certificate = $credentials['certificate']; $connection = $this->getConduit()->callMethodSynchronous( 'conduit.connect', array( 'client' => 'arc', 'clientVersion' => $this->getConduitVersion(), 'clientDescription' => php_uname('n').':'.$description, 'user' => $user, 'certificate' => $certificate, 'host' => $this->conduitURI, )); } catch (ConduitClientException $ex) { if ($ex->getErrorCode() == 'ERR-NO-CERTIFICATE' || $ex->getErrorCode() == 'ERR-INVALID-USER' || $ex->getErrorCode() == 'ERR-INVALID-AUTH') { $conduit_uri = $this->conduitURI; $message = phutil_console_format( "\n%s\n\n %s\n\n%s\n%s", pht('YOU NEED TO __INSTALL A CERTIFICATE__ TO LOGIN TO PHABRICATOR'), pht('To do this, run: **%s**', 'arc install-certificate'), pht("The server '%s' rejected your request:", $conduit_uri), $ex->getMessage()); throw new ArcanistUsageException($message); } else if ($ex->getErrorCode() == 'NEW-ARC-VERSION') { // Cleverly disguise this as being AWESOME!!! echo phutil_console_format("**%s**\n\n", pht('New Version Available!')); echo phutil_console_wrap($ex->getMessage()); echo "\n\n"; echo pht('In most cases, arc can be upgraded automatically.')."\n"; $ok = phutil_console_confirm( pht('Upgrade arc now?'), $default_no = false); if (!$ok) { throw $ex; } $root = dirname(phutil_get_library_root('arcanist')); chdir($root); $err = phutil_passthru('%s upgrade', $root.'/bin/arc'); if (!$err) { echo "\n".pht('Try running your arc command again.')."\n"; } exit(1); } else { throw $ex; } } $this->userName = $user; $this->userPHID = $connection['userPHID']; $this->conduitAuthenticated = true; return $this; } /** * @return bool True if conduit is authenticated, false otherwise. * @task conduit */ final protected function isConduitAuthenticated() { return (bool)$this->conduitAuthenticated; } /** * Override this to return true if your workflow requires a conduit channel. * Arc will build the channel for you before your workflow executes. This * implies that you only need an unauthenticated channel; if you need * authentication, override @{method:requiresAuthentication}. * * @return bool True if arc should build a conduit channel before running * the workflow. * @task conduit */ public function requiresConduit() { return false; } /** * Override this to return true if your workflow requires an authenticated * conduit channel. This implies that it requires a conduit. Arc will build * and authenticate the channel for you before the workflow executes. * * @return bool True if arc should build an authenticated conduit channel * before running the workflow. * @task conduit */ public function requiresAuthentication() { return false; } /** * Returns the PHID for the user once they've authenticated via Conduit. * * @return phid Authenticated user PHID. * @task conduit */ final public function getUserPHID() { if (!$this->userPHID) { $workflow = get_class($this); throw new Exception( pht( "This workflow ('%s') requires authentication, override ". "%s to return true.", $workflow, 'requiresAuthentication()')); } return $this->userPHID; } /** * Return the username for the user once they've authenticated via Conduit. * * @return string Authenticated username. * @task conduit */ final public function getUserName() { return $this->userName; } /** * Get the established @{class@libphutil:ConduitClient} in order to make * Conduit method calls. Before the client is available it must be connected, * either implicitly by making @{method:requireConduit} or * @{method:requireAuthentication} return true, or explicitly by calling * @{method:establishConduit} or @{method:authenticateConduit}. * * @return @{class@libphutil:ConduitClient} Live conduit client. * @task conduit */ final public function getConduit() { if (!$this->conduit) { $workflow = get_class($this); throw new Exception( pht( "This workflow ('%s') requires a Conduit, override ". "%s to return true.", $workflow, 'requiresConduit()')); } return $this->conduit; } final public function setArcanistConfiguration( ArcanistConfiguration $arcanist_configuration) { $this->arcanistConfiguration = $arcanist_configuration; return $this; } final public function getArcanistConfiguration() { return $this->arcanistConfiguration; } final public function setConfigurationManager( ArcanistConfigurationManager $arcanist_configuration_manager) { $this->configurationManager = $arcanist_configuration_manager; return $this; } final public function getConfigurationManager() { return $this->configurationManager; } public function requiresWorkingCopy() { return false; } public function desiresWorkingCopy() { return false; } public function requiresRepositoryAPI() { return false; } public function desiresRepositoryAPI() { return false; } final public function setCommand($command) { $this->command = $command; return $this; } final public function getCommand() { return $this->command; } public function getArguments() { return array(); } final public function setWorkingDirectory($working_directory) { $this->workingDirectory = $working_directory; return $this; } final public function getWorkingDirectory() { return $this->workingDirectory; } final private function setParentWorkflow($parent_workflow) { $this->parentWorkflow = $parent_workflow; return $this; } final protected function getParentWorkflow() { return $this->parentWorkflow; } final public function buildChildWorkflow($command, array $argv) { $arc_config = $this->getArcanistConfiguration(); $workflow = $arc_config->buildWorkflow($command); $workflow->setParentWorkflow($this); $workflow->setConduitEngine($this->getConduitEngine()); $workflow->setCommand($command); $workflow->setConfigurationManager($this->getConfigurationManager()); if ($this->repositoryAPI) { $workflow->setRepositoryAPI($this->repositoryAPI); } if ($this->userPHID) { $workflow->userPHID = $this->getUserPHID(); $workflow->userName = $this->getUserName(); } if ($this->conduit) { $workflow->conduit = $this->conduit; $workflow->setConduitCredentials($this->conduitCredentials); $workflow->conduitAuthenticated = $this->conduitAuthenticated; } $workflow->setArcanistConfiguration($arc_config); $workflow->parseArguments(array_values($argv)); return $workflow; } final public function getArgument($key, $default = null) { // TOOLSETS: Remove this legacy code. if (is_array($this->arguments)) { return idx($this->arguments, $key, $default); } return $this->arguments->getArg($key); } final public function getPassedArguments() { return $this->passedArguments; } final public function getCompleteArgumentSpecification() { $spec = $this->getArguments(); $arc_config = $this->getArcanistConfiguration(); $command = $this->getCommand(); $spec += $arc_config->getCustomArgumentsForCommand($command); return $spec; } final public function parseArguments(array $args) { $this->passedArguments = $args; $spec = $this->getCompleteArgumentSpecification(); $dict = array(); $more_key = null; if (!empty($spec['*'])) { $more_key = $spec['*']; unset($spec['*']); $dict[$more_key] = array(); } $short_to_long_map = array(); foreach ($spec as $long => $options) { if (!empty($options['short'])) { $short_to_long_map[$options['short']] = $long; } } foreach ($spec as $long => $options) { if (!empty($options['repeat'])) { $dict[$long] = array(); } } $more = array(); $size = count($args); for ($ii = 0; $ii < $size; $ii++) { $arg = $args[$ii]; $arg_name = null; $arg_key = null; if ($arg == '--') { $more = array_merge( $more, array_slice($args, $ii + 1)); break; } else if (!strncmp($arg, '--', 2)) { $arg_key = substr($arg, 2); $parts = explode('=', $arg_key, 2); if (count($parts) == 2) { list($arg_key, $val) = $parts; array_splice($args, $ii, 1, array('--'.$arg_key, $val)); $size++; } if (!array_key_exists($arg_key, $spec)) { $corrected = PhutilArgumentSpellingCorrector::newFlagCorrector() ->correctSpelling($arg_key, array_keys($spec)); if (count($corrected) == 1) { PhutilConsole::getConsole()->writeErr( pht( "(Assuming '%s' is the British spelling of '%s'.)", '--'.$arg_key, '--'.head($corrected))."\n"); $arg_key = head($corrected); } else { throw new ArcanistUsageException( pht( "Unknown argument '%s'. Try '%s'.", $arg_key, 'arc help')); } } } else if (!strncmp($arg, '-', 1)) { $arg_key = substr($arg, 1); if (empty($short_to_long_map[$arg_key])) { throw new ArcanistUsageException( pht( "Unknown argument '%s'. Try '%s'.", $arg_key, 'arc help')); } $arg_key = $short_to_long_map[$arg_key]; } else { $more[] = $arg; continue; } $options = $spec[$arg_key]; if (empty($options['param'])) { $dict[$arg_key] = true; } else { if ($ii == $size - 1) { throw new ArcanistUsageException( pht( "Option '%s' requires a parameter.", $arg)); } if (!empty($options['repeat'])) { $dict[$arg_key][] = $args[$ii + 1]; } else { $dict[$arg_key] = $args[$ii + 1]; } $ii++; } } if ($more) { if ($more_key) { $dict[$more_key] = $more; } else { $example = reset($more); throw new ArcanistUsageException( pht( "Unrecognized argument '%s'. Try '%s'.", $example, 'arc help')); } } foreach ($dict as $key => $value) { if (empty($spec[$key]['conflicts'])) { continue; } foreach ($spec[$key]['conflicts'] as $conflict => $more) { if (isset($dict[$conflict])) { if ($more) { $more = ': '.$more; } else { $more = '.'; } // TODO: We'll always display these as long-form, when the user might // have typed them as short form. throw new ArcanistUsageException( pht( "Arguments '%s' and '%s' are mutually exclusive", "--{$key}", "--{$conflict}").$more); } } } $this->arguments = $dict; $this->didParseArguments(); return $this; } protected function didParseArguments() { // Override this to customize workflow argument behavior. } final public function getWorkingCopy() { $working_copy = $this->getConfigurationManager()->getWorkingCopyIdentity(); if (!$working_copy) { $workflow = get_class($this); throw new Exception( pht( "This workflow ('%s') requires a working copy, override ". "%s to return true.", $workflow, 'requiresWorkingCopy()')); } return $working_copy; } final public function setRepositoryAPI($api) { $this->repositoryAPI = $api; return $this; } final public function hasRepositoryAPI() { try { return (bool)$this->getRepositoryAPI(); } catch (Exception $ex) { return false; } } final public function getRepositoryAPI() { if (!$this->repositoryAPI) { $workflow = get_class($this); throw new Exception( pht( "This workflow ('%s') requires a Repository API, override ". "%s to return true.", $workflow, 'requiresRepositoryAPI()')); } return $this->repositoryAPI; } final protected function shouldRequireCleanUntrackedFiles() { return empty($this->arguments['allow-untracked']); } final public function setCommitMode($mode) { $this->commitMode = $mode; return $this; } final public function finalizeWorkingCopy() { if ($this->stashed) { $api = $this->getRepositoryAPI(); $api->unstashChanges(); echo pht('Restored stashed changes to the working directory.')."\n"; } } final public function requireCleanWorkingCopy() { $api = $this->getRepositoryAPI(); $must_commit = array(); $working_copy_desc = phutil_console_format( " %s: __%s__\n\n", pht('Working copy'), $api->getPath()); // NOTE: this is a subversion-only concept. $incomplete = $api->getIncompleteChanges(); if ($incomplete) { throw new ArcanistUsageException( sprintf( "%s\n\n%s %s\n %s\n\n%s", pht( "You have incompletely checked out directories in this working ". "copy. Fix them before proceeding.'"), $working_copy_desc, pht('Incomplete directories in working copy:'), implode("\n ", $incomplete), pht( "You can fix these paths by running '%s' on them.", 'svn update'))); } $conflicts = $api->getMergeConflicts(); if ($conflicts) { throw new ArcanistUsageException( sprintf( "%s\n\n%s %s\n %s", pht( 'You have merge conflicts in this working copy. Resolve merge '. 'conflicts before proceeding.'), $working_copy_desc, pht('Conflicts in working copy:'), implode("\n ", $conflicts))); } $missing = $api->getMissingChanges(); if ($missing) { throw new ArcanistUsageException( sprintf( "%s\n\n%s %s\n %s\n", pht( 'You have missing files in this working copy. Revert or formally '. 'remove them (with `%s`) before proceeding.', 'svn rm'), $working_copy_desc, pht('Missing files in working copy:'), implode("\n ", $missing))); } $externals = $api->getDirtyExternalChanges(); // TODO: This state can exist in Subversion, but it is currently handled // elsewhere. It should probably be handled here, eventually. if ($api instanceof ArcanistSubversionAPI) { $externals = array(); } 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(); // 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); $untracked = $api->getUntrackedChanges(); if (!$this->shouldRequireCleanUntrackedFiles()) { $untracked = array(); } if ($untracked) { echo sprintf( "%s\n\n%s", pht('You have untracked files in this working copy.'), $working_copy_desc); if ($api instanceof ArcanistGitAPI) { $hint = pht( '(To ignore these %s change(s), add them to "%s".)', phutil_count($untracked), '.git/info/exclude'); } else if ($api instanceof ArcanistSubversionAPI) { $hint = pht( '(To ignore these %s change(s), add them to "%s".)', phutil_count($untracked), 'svn:ignore'); } else if ($api instanceof ArcanistMercurialAPI) { $hint = pht( '(To ignore these %s change(s), add them to "%s".)', phutil_count($untracked), '.hgignore'); } $untracked_list = " ".implode("\n ", $untracked); echo sprintf( " %s\n %s\n%s", pht('Untracked changes in working copy:'), $hint, $untracked_list); $prompt = pht( 'Ignore these %s untracked file(s) and continue?', phutil_count($untracked)); if (!phutil_console_confirm($prompt)) { throw new ArcanistUserAbortException(); } } $should_commit = false; if ($unstaged || $uncommitted) { // NOTE: We're running this because it builds a cache and can take a // perceptible amount of time to arrive at an answer, but we don't want // to pause in the middle of printing the output below. $this->getShouldAmend(); echo sprintf( "%s\n\n%s", pht('You have uncommitted changes in this working copy.'), $working_copy_desc); $lists = array(); if ($unstaged) { $unstaged_list = " ".implode("\n ", $unstaged); $lists[] = sprintf( " %s\n%s", pht('Unstaged changes in working copy:'), $unstaged_list); } if ($uncommitted) { $uncommitted_list = " ".implode("\n ", $uncommitted); $lists[] = sprintf( "%s\n%s", pht('Uncommitted changes in working copy:'), $uncommitted_list); } echo implode("\n\n", $lists)."\n"; $all_uncommitted = array_merge($unstaged, $uncommitted); if ($this->askForAdd($all_uncommitted)) { if ($unstaged) { $api->addToCommit($unstaged); } $should_commit = true; } else { $permit_autostash = $this->getConfigFromAnySource('arc.autostash'); if ($permit_autostash && $api->canStashChanges()) { echo pht( 'Stashing uncommitted changes. (You can restore them with `%s`).', 'git stash pop')."\n"; $api->stashChanges(); $this->stashed = true; } else { throw new ArcanistUsageException( pht( 'You can not continue with uncommitted changes. '. 'Commit or discard them before proceeding.')); } } } if ($should_commit) { if ($this->getShouldAmend()) { $commit = head($api->getLocalCommitInformation()); $api->amendCommit($commit['message']); } else if ($api->supportsLocalCommits()) { $template = sprintf( "\n\n# %s\n#\n# %s\n#\n", pht('Enter a commit message.'), pht('Changes:')); $paths = array_merge($uncommitted, $unstaged); $paths = array_unique($paths); sort($paths); foreach ($paths as $path) { $template .= "# ".$path."\n"; } $commit_message = $this->newInteractiveEditor($template) ->setName(pht('commit-message')) ->editInteractively(); if ($commit_message === $template) { throw new ArcanistUsageException( pht('You must provide a commit message.')); } $commit_message = ArcanistCommentRemover::removeComments( $commit_message); if (!strlen($commit_message)) { throw new ArcanistUsageException( pht('You must provide a nonempty commit message.')); } $api->doCommit($commit_message); } } } private function getShouldAmend() { if ($this->shouldAmend === null) { $this->shouldAmend = $this->calculateShouldAmend(); } return $this->shouldAmend; } private function calculateShouldAmend() { $api = $this->getRepositoryAPI(); if ($this->isHistoryImmutable() || !$api->supportsAmend()) { return false; } $commits = $api->getLocalCommitInformation(); if (!$commits) { return false; } $commit = reset($commits); $message = ArcanistDifferentialCommitMessage::newFromRawCorpus( $commit['message']); if ($message->getGitSVNBaseRevision()) { return false; } if ($api->getAuthor() != $commit['author']) { return false; } if ($message->getRevisionID() && $this->getArgument('create')) { return false; } // TODO: Check commits since tracking branch. If empty then return false. // Don't amend the current commit if it has already been published. $repository = $this->loadProjectRepository(); if ($repository) { $repo_id = $repository['id']; $commit_hash = $commit['commit']; $callsign = idx($repository, 'callsign'); if ($callsign) { // The server might be too old to support the new style commit names, // so prefer the old way $commit_name = "r{$callsign}{$commit_hash}"; } else { $commit_name = "R{$repo_id}:{$commit_hash}"; } $result = $this->getConduit()->callMethodSynchronous( 'diffusion.querycommits', array('names' => array($commit_name))); $known_commit = idx($result['identifierMap'], $commit_name); if ($known_commit) { return false; } } if (!$message->getRevisionID()) { return true; } $in_working_copy = $api->loadWorkingCopyDifferentialRevisions( $this->getConduit(), array( 'authors' => array($this->getUserPHID()), 'status' => 'status-open', )); if ($in_working_copy) { return true; } return false; } private function askForAdd(array $files) { if ($this->commitMode == self::COMMIT_DISABLE) { return false; } if ($this->commitMode == self::COMMIT_ENABLE) { return true; } $prompt = $this->getAskForAddPrompt($files); return phutil_console_confirm($prompt); } private function getAskForAddPrompt(array $files) { if ($this->getShouldAmend()) { $prompt = pht( 'Do you want to amend these %s change(s) to the current commit?', phutil_count($files)); } else { $prompt = pht( 'Do you want to create a new commit with these %s change(s)?', phutil_count($files)); } return $prompt; } final protected function loadDiffBundleFromConduit( ConduitClient $conduit, $diff_id) { return $this->loadBundleFromConduit( $conduit, array( 'ids' => array($diff_id), )); } final protected function loadRevisionBundleFromConduit( ConduitClient $conduit, $revision_id) { return $this->loadBundleFromConduit( $conduit, array( 'revisionIDs' => array($revision_id), )); } final private function loadBundleFromConduit( ConduitClient $conduit, $params) { $future = $conduit->callMethod('differential.querydiffs', $params); $diff = head($future->resolve()); if ($diff == null) { throw new Exception( phutil_console_wrap( pht("The diff or revision you specified is either invalid or you ". "don't have permission to view it.")) ); } $changes = array(); foreach ($diff['changes'] as $changedict) { $changes[] = ArcanistDiffChange::newFromDictionary($changedict); } $bundle = ArcanistBundle::newFromChanges($changes); $bundle->setConduit($conduit); // since the conduit method has changes, assume that these fields // could be unset $bundle->setBaseRevision(idx($diff, 'sourceControlBaseRevision')); $bundle->setRevisionID(idx($diff, 'revisionID')); $bundle->setAuthorName(idx($diff, 'authorName')); $bundle->setAuthorEmail(idx($diff, 'authorEmail')); return $bundle; } /** * Return a list of lines changed by the current diff, or ##null## if the * change list is meaningless (for example, because the path is a directory * or binary file). * * @param string Path within the repository. * @param string Change selection mode (see ArcanistDiffHunk). * @return list|null List of changed line numbers, or null to indicate that * the path is not a line-oriented text file. */ final protected function getChangedLines($path, $mode) { $repository_api = $this->getRepositoryAPI(); $full_path = $repository_api->getPath($path); if (is_dir($full_path)) { return null; } if (!file_exists($full_path)) { return null; } $change = $this->getChange($path); if ($change->getFileType() !== ArcanistDiffChangeType::FILE_TEXT) { return null; } $lines = $change->getChangedLines($mode); return array_keys($lines); } final protected function getChange($path) { $repository_api = $this->getRepositoryAPI(); // TODO: Very gross $is_git = ($repository_api instanceof ArcanistGitAPI); $is_hg = ($repository_api instanceof ArcanistMercurialAPI); $is_svn = ($repository_api instanceof ArcanistSubversionAPI); if ($is_svn) { // NOTE: In SVN, we don't currently support a "get all local changes" // operation, so special case it. if (empty($this->changeCache[$path])) { $diff = $repository_api->getRawDiffText($path); $parser = $this->newDiffParser(); $changes = $parser->parseDiff($diff); if (count($changes) != 1) { throw new Exception(pht('Expected exactly one change.')); } $this->changeCache[$path] = reset($changes); } } else if ($is_git || $is_hg) { if (empty($this->changeCache)) { $changes = $repository_api->getAllLocalChanges(); foreach ($changes as $change) { $this->changeCache[$change->getCurrentPath()] = $change; } } } else { throw new Exception(pht('Missing VCS support.')); } if (empty($this->changeCache[$path])) { if ($is_git || $is_hg) { // This can legitimately occur under git/hg if you make a change, // "git/hg commit" it, and then revert the change in the working copy // and run "arc lint". $change = new ArcanistDiffChange(); $change->setCurrentPath($path); return $change; } else { throw new Exception( pht( "Trying to get change for unchanged path '%s'!", $path)); } } return $this->changeCache[$path]; } final public function willRunWorkflow() { $spec = $this->getCompleteArgumentSpecification(); foreach ($this->arguments as $arg => $value) { if (empty($spec[$arg])) { continue; } $options = $spec[$arg]; if (!empty($options['supports'])) { $system_name = $this->getRepositoryAPI()->getSourceControlSystemName(); if (!in_array($system_name, $options['supports'])) { $extended_info = null; if (!empty($options['nosupport'][$system_name])) { $extended_info = ' '.$options['nosupport'][$system_name]; } throw new ArcanistUsageException( pht( "Option '%s' is not supported under %s.", "--{$arg}", $system_name). $extended_info); } } } } final protected function normalizeRevisionID($revision_id) { return preg_replace('/^D/i', '', $revision_id); } protected function shouldShellComplete() { return true; } protected function getShellCompletions(array $argv) { return array(); } public function getSupportedRevisionControlSystems() { return array('git', 'hg', 'svn'); } final protected function getPassthruArgumentsAsMap($command) { $map = array(); foreach ($this->getCompleteArgumentSpecification() as $key => $spec) { if (!empty($spec['passthru'][$command])) { if (isset($this->arguments[$key])) { $map[$key] = $this->arguments[$key]; } } } return $map; } final protected function getPassthruArgumentsAsArgv($command) { $spec = $this->getCompleteArgumentSpecification(); $map = $this->getPassthruArgumentsAsMap($command); $argv = array(); foreach ($map as $key => $value) { $argv[] = '--'.$key; if (!empty($spec[$key]['param'])) { $argv[] = $value; } } return $argv; } /** * Write a message to stderr so that '--json' flags or stdout which is meant * to be piped somewhere aren't disrupted. * * @param string Message to write to stderr. * @return void */ final protected function writeStatusMessage($msg) { fwrite(STDERR, $msg); } final public function writeInfo($title, $message) { $this->writeStatusMessage( phutil_console_format( "** %s ** %s\n", $title, $message)); } final public function writeWarn($title, $message) { $this->writeStatusMessage( phutil_console_format( "** %s ** %s\n", $title, $message)); } final public function writeOkay($title, $message) { $this->writeStatusMessage( phutil_console_format( "** %s ** %s\n", $title, $message)); } final protected function isHistoryImmutable() { $repository_api = $this->getRepositoryAPI(); $config = $this->getConfigFromAnySource('history.immutable'); if ($config !== null) { return $config; } return $repository_api->isHistoryDefaultImmutable(); } /** * Workflows like 'lint' and 'unit' operate on a list of working copy paths. * The user can either specify the paths explicitly ("a.js b.php"), or by * specifying a revision ("--rev a3f10f1f") to select all paths modified * since that revision, or by omitting both and letting arc choose the * default relative revision. * * This method takes the user's selections and returns the paths that the * workflow should act upon. * * @param list List of explicitly provided paths. * @param string|null Revision name, if provided. * @param mask Mask of ArcanistRepositoryAPI flags to exclude. * Defaults to ArcanistRepositoryAPI::FLAG_UNTRACKED. * @return list List of paths the workflow should act on. */ final protected function selectPathsForWorkflow( array $paths, $rev, $omit_mask = null) { if ($omit_mask === null) { $omit_mask = ArcanistRepositoryAPI::FLAG_UNTRACKED; } if ($paths) { $working_copy = $this->getWorkingCopy(); foreach ($paths as $key => $path) { $full_path = Filesystem::resolvePath($path); if (!Filesystem::pathExists($full_path)) { throw new ArcanistUsageException( pht( "Path '%s' does not exist!", $path)); } $relative_path = Filesystem::readablePath( $full_path, $working_copy->getProjectRoot()); $paths[$key] = $relative_path; } } else { $repository_api = $this->getRepositoryAPI(); if ($rev) { $this->parseBaseCommitArgument(array($rev)); } $paths = $repository_api->getWorkingCopyStatus(); foreach ($paths as $path => $flags) { if ($flags & $omit_mask) { unset($paths[$path]); } } $paths = array_keys($paths); } return array_values($paths); } final protected function renderRevisionList(array $revisions) { $list = array(); foreach ($revisions as $revision) { $list[] = ' - D'.$revision['id'].': '.$revision['title']."\n"; } return implode('', $list); } /* -( 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 */ final protected function readScratchFile($path) { if (!$this->repositoryAPI) { return false; } return $this->getRepositoryAPI()->readScratchFile($path); } /** * Try to read a scratch JSON file, if it exists and is readable. * * @param string Scratch file name. * @return array Empty array for failure. * @task scratch */ final protected function readScratchJSONFile($path) { $file = $this->readScratchFile($path); if (!$file) { return array(); } return phutil_json_decode($file); } /** * 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 */ final protected function writeScratchFile($path, $data) { if (!$this->repositoryAPI) { return false; } return $this->getRepositoryAPI()->writeScratchFile($path, $data); } /** * Try to write a scratch JSON file, if there's somewhere to put it and we can * write there. * * @param string Scratch file name to write. * @param array Data to write. * @return bool True on success, false on failure. * @task scratch */ final protected function writeScratchJSONFile($path, array $data) { return $this->writeScratchFile($path, json_encode($data)); } /** * Try to remove a scratch file. * * @param string Scratch file name to remove. * @return bool True if the file was removed successfully. * @task scratch */ final protected function removeScratchFile($path) { if (!$this->repositoryAPI) { return false; } return $this->getRepositoryAPI()->removeScratchFile($path); } /** * Get a human-readable description of the scratch file location. * * @param string Scratch file name. * @return mixed String, or false on failure. * @task scratch */ final protected function getReadableScratchFilePath($path) { if (!$this->repositoryAPI) { return false; } return $this->getRepositoryAPI()->getReadableScratchFilePath($path); } /** * Get the path to a scratch file, if possible. * * @param string Scratch file name. * @return mixed File path, or false on failure. * @task scratch */ final protected function getScratchFilePath($path) { if (!$this->repositoryAPI) { return false; } return $this->getRepositoryAPI()->getScratchFilePath($path); } final protected function getRepositoryEncoding() { return nonempty( idx($this->loadProjectRepository(), 'encoding'), 'UTF-8'); } final protected function loadProjectRepository() { list($info, $reasons) = $this->loadRepositoryInformation(); return coalesce($info, array()); } final protected function newInteractiveEditor($text) { $editor = new PhutilInteractiveEditor($text); $preferred = $this->getConfigFromAnySource('editor'); if ($preferred) { $editor->setPreferredEditor($preferred); } return $editor; } final protected function newDiffParser() { $parser = new ArcanistDiffParser(); if ($this->repositoryAPI) { $parser->setRepositoryAPI($this->getRepositoryAPI()); } $parser->setWriteDiffOnFailure(true); return $parser; } - final protected function resolveCall(ConduitFuture $method, $timeout = null) { + final protected function resolveCall(ConduitFuture $method) { try { - return $method->resolve($timeout); + return $method->resolve(); } catch (ConduitClientException $ex) { if ($ex->getErrorCode() == 'ERR-CONDUIT-CALL') { echo phutil_console_wrap( pht( 'This feature requires a newer version of Phabricator. Please '. 'update it using these instructions: %s', 'https://secure.phabricator.com/book/phabricator/article/'. 'upgrading/')."\n\n"); } throw $ex; } } final protected function dispatchEvent($type, array $data) { $data += array( 'workflow' => $this, ); $event = new PhutilEvent($type, $data); PhutilEventEngine::dispatchEvent($event); return $event; } final public function parseBaseCommitArgument(array $argv) { if (!count($argv)) { return; } $api = $this->getRepositoryAPI(); if (!$api->supportsCommitRanges()) { throw new ArcanistUsageException( pht('This version control system does not support commit ranges.')); } if (count($argv) > 1) { throw new ArcanistUsageException( pht( 'Specify exactly one base commit. The end of the commit range is '. 'always the working copy state.')); } $api->setBaseCommit(head($argv)); return $this; } final protected function getRepositoryVersion() { if (!$this->repositoryVersion) { $api = $this->getRepositoryAPI(); $commit = $api->getSourceControlBaseRevision(); $versions = array('' => $commit); foreach ($api->getChangedFiles($commit) as $path => $mask) { $versions[$path] = (Filesystem::pathExists($path) ? md5_file($path) : ''); } $this->repositoryVersion = md5(json_encode($versions)); } return $this->repositoryVersion; } /* -( Phabricator Repositories )------------------------------------------- */ /** * Get the PHID of the Phabricator repository this working copy corresponds * to. Returns `null` if no repository can be identified. * * @return phid|null Repository PHID, or null if no repository can be * identified. * * @task phabrep */ final protected function getRepositoryPHID() { return idx($this->getRepositoryInformation(), 'phid'); } /** * Get the name of the Phabricator repository this working copy * corresponds to. Returns `null` if no repository can be identified. * * @return string|null Repository name, or null if no repository can be * identified. * * @task phabrep */ final protected function getRepositoryName() { return idx($this->getRepositoryInformation(), 'name'); } /** * Get the URI of the Phabricator repository this working copy * corresponds to. Returns `null` if no repository can be identified. * * @return string|null Repository URI, or null if no repository can be * identified. * * @task phabrep */ final protected function getRepositoryURI() { return idx($this->getRepositoryInformation(), 'uri'); } final protected function getRepositoryStagingConfiguration() { return idx($this->getRepositoryInformation(), 'staging'); } /** * Get human-readable reasoning explaining how `arc` evaluated which * Phabricator repository corresponds to this working copy. Used by * `arc which` to explain the process to users. * * @return list Human-readable explanation of the repository * association process. * * @task phabrep */ final protected function getRepositoryReasons() { $this->getRepositoryInformation(); return $this->repositoryReasons; } /** * @task phabrep */ private function getRepositoryInformation() { if ($this->repositoryInfo === null) { list($info, $reasons) = $this->loadRepositoryInformation(); $this->repositoryInfo = nonempty($info, array()); $this->repositoryReasons = $reasons; } return $this->repositoryInfo; } /** * @task phabrep */ private function loadRepositoryInformation() { list($query, $reasons) = $this->getRepositoryQuery(); if (!$query) { return array(null, $reasons); } try { $method = 'repository.query'; $results = $this->getConduitEngine()->newCall($method, $query) ->resolve(); } catch (ConduitClientException $ex) { if ($ex->getErrorCode() == 'ERR-CONDUIT-CALL') { $reasons[] = pht( 'This version of Arcanist is more recent than the version of '. 'Phabricator you are connecting to: the Phabricator install is '. 'out of date and does not have support for identifying '. 'repositories by callsign or URI. Update Phabricator to enable '. 'these features.'); return array(null, $reasons); } throw $ex; } $result = null; if (!$results) { $reasons[] = pht( 'No repositories matched the query. Check that your configuration '. 'is correct, or use "%s" to select a repository explicitly.', 'repository.callsign'); } else if (count($results) > 1) { $reasons[] = pht( 'Multiple repostories (%s) matched the query. You can use the '. '"%s" configuration to select the one you want.', implode(', ', ipull($results, 'callsign')), 'repository.callsign'); } else { $result = head($results); $reasons[] = pht('Found a unique matching repository.'); } return array($result, $reasons); } /** * @task phabrep */ private function getRepositoryQuery() { $reasons = array(); $callsign = $this->getConfigFromAnySource('repository.callsign'); if ($callsign) { $query = array( 'callsigns' => array($callsign), ); $reasons[] = pht( 'Configuration value "%s" is set to "%s".', 'repository.callsign', $callsign); return array($query, $reasons); } else { $reasons[] = pht( 'Configuration value "%s" is empty.', 'repository.callsign'); } $uuid = $this->getRepositoryAPI()->getRepositoryUUID(); if ($uuid !== null) { $query = array( 'uuids' => array($uuid), ); $reasons[] = pht( 'The UUID for this working copy is "%s".', $uuid); return array($query, $reasons); } else { $reasons[] = pht( 'This repository has no VCS UUID (this is normal for git/hg).'); } $remote_uri = $this->getRepositoryAPI()->getRemoteURI(); if ($remote_uri !== null) { $query = array( 'remoteURIs' => array($remote_uri), ); $reasons[] = pht( 'The remote URI for this working copy is "%s".', $remote_uri); return array($query, $reasons); } else { $reasons[] = pht( 'Unable to determine the remote URI for this repository.'); } return array(null, $reasons); } /** * Build a new lint engine for the current working copy. * * Optionally, you can pass an explicit engine class name to build an engine * of a particular class. Normally this is used to implement an `--engine` * flag from the CLI. * * @param string Optional explicit engine class name. * @return ArcanistLintEngine Constructed engine. */ protected function newLintEngine($engine_class = null) { $working_copy = $this->getWorkingCopy(); $config = $this->getConfigurationManager(); if (!$engine_class) { $engine_class = $config->getConfigFromAnySource('lint.engine'); } if (!$engine_class) { if (Filesystem::pathExists($working_copy->getProjectPath('.arclint'))) { $engine_class = 'ArcanistConfigurationDrivenLintEngine'; } } if (!$engine_class) { throw new ArcanistNoEngineException( pht( "No lint engine is configured for this project. Create an '%s' ". "file, or configure an advanced engine with '%s' in '%s'.", '.arclint', 'lint.engine', '.arcconfig')); } $base_class = 'ArcanistLintEngine'; if (!class_exists($engine_class) || !is_subclass_of($engine_class, $base_class)) { throw new ArcanistUsageException( pht( 'Configured lint engine "%s" is not a subclass of "%s", but must be.', $engine_class, $base_class)); } $engine = newv($engine_class, array()) ->setWorkingCopy($working_copy) ->setConfigurationManager($config); return $engine; } /** * Build a new unit test engine for the current working copy. * * Optionally, you can pass an explicit engine class name to build an engine * of a particular class. Normally this is used to implement an `--engine` * flag from the CLI. * * @param string Optional explicit engine class name. * @return ArcanistUnitTestEngine Constructed engine. */ protected function newUnitTestEngine($engine_class = null) { $working_copy = $this->getWorkingCopy(); $config = $this->getConfigurationManager(); if (!$engine_class) { $engine_class = $config->getConfigFromAnySource('unit.engine'); } if (!$engine_class) { if (Filesystem::pathExists($working_copy->getProjectPath('.arcunit'))) { $engine_class = 'ArcanistConfigurationDrivenUnitTestEngine'; } } if (!$engine_class) { throw new ArcanistNoEngineException( pht( "No unit test engine is configured for this project. Create an ". "'%s' file, or configure an advanced engine with '%s' in '%s'.", '.arcunit', 'unit.engine', '.arcconfig')); } $base_class = 'ArcanistUnitTestEngine'; if (!class_exists($engine_class) || !is_subclass_of($engine_class, $base_class)) { throw new ArcanistUsageException( pht( 'Configured unit test engine "%s" is not a subclass of "%s", '. 'but must be.', $engine_class, $base_class)); } $engine = newv($engine_class, array()) ->setWorkingCopy($working_copy) ->setConfigurationManager($config); return $engine; } protected function openURIsInBrowser(array $uris) { $browser = $this->getBrowserCommand(); foreach ($uris as $uri) { $err = phutil_passthru('%s %s', $browser, $uri); if ($err) { throw new ArcanistUsageException( pht( "Failed to open '%s' in browser ('%s'). ". "Check your 'browser' config option.", $uri, $browser)); } } } private function getBrowserCommand() { $config = $this->getConfigFromAnySource('browser'); if ($config) { return $config; } if (phutil_is_windows()) { return 'start'; } $candidates = array('sensible-browser', 'xdg-open', 'open'); // NOTE: The "open" command works well on OS X, but on many Linuxes "open" // exists and is not a browser. For now, we're just looking for other // commands first, but we might want to be smarter about selecting "open" // only on OS X. foreach ($candidates as $cmd) { if (Filesystem::binaryExists($cmd)) { return $cmd; } } throw new ArcanistUsageException( pht( "Unable to find a browser command to run. Set '%s' in your ". "Arcanist config to specify a command to use.", 'browser')); } /** * Ask Phabricator to update the current repository as soon as possible. * * Calling this method after pushing commits allows Phabricator to discover * the commits more quickly, so the system overall is more responsive. * * @return void */ protected function askForRepositoryUpdate() { // If we know which repository we're in, try to tell Phabricator that we // pushed commits to it so it can update. This hint can help pull updates // more quickly, especially in rarely-used repositories. if ($this->getRepositoryPHID()) { try { $this->getConduit()->callMethodSynchronous( 'diffusion.looksoon', array( 'repositories' => array($this->getRepositoryPHID()), )); } catch (ConduitClientException $ex) { // If we hit an exception, just ignore it. Likely, we are running // against a Phabricator which is too old to support this method. // Since this hint is purely advisory, it doesn't matter if it has // no effect. } } } protected function getModernLintDictionary(array $map) { $map = $this->getModernCommonDictionary($map); return $map; } protected function getModernUnitDictionary(array $map) { $map = $this->getModernCommonDictionary($map); $details = idx($map, 'userData'); if (strlen($details)) { $map['details'] = (string)$details; } unset($map['userData']); return $map; } private function getModernCommonDictionary(array $map) { foreach ($map as $key => $value) { if ($value === null) { unset($map[$key]); } } return $map; } final public function setConduitEngine( ArcanistConduitEngine $conduit_engine) { $this->conduitEngine = $conduit_engine; return $this; } final public function getConduitEngine() { return $this->conduitEngine; } final protected function newWorkingCopyStateRef() { $ref = new ArcanistWorkingCopyStateRef(); $working_copy = $this->getWorkingCopy(); $ref->setRootDirectory($working_copy->getProjectRoot()); return $ref; } final protected function newRefQuery(array $refs) { assert_instances_of($refs, 'ArcanistRef'); $query = id(new ArcanistRefQuery()) ->setConduitEngine($this->getConduitEngine()) ->setRefs($refs); if ($this->hasRepositoryAPI()) { $query->setRepositoryAPI($this->getRepositoryAPI()); } $repository_ref = $this->getRepositoryRef(); if ($repository_ref) { $query->setRepositoryRef($repository_ref); } $working_copy = $this->getConfigurationManager()->getWorkingCopyIdentity(); if ($working_copy) { $working_ref = $this->newWorkingCopyStateRef(); $query->setWorkingCopyRef($working_ref); } return $query; } final public function getRepositoryRef() { if (!$this->getConfigurationManager()->getWorkingCopyIdentity()) { return null; } if (!$this->repositoryAPI) { return null; } if (!$this->repositoryRef) { $ref = id(new ArcanistRepositoryRef()) ->setPHID($this->getRepositoryPHID()) ->setBrowseURI($this->getRepositoryURI()); $this->repositoryRef = $ref; } return $this->repositoryRef; } final public function getToolsetKey() { return $this->getToolset()->getToolsetKey(); } final public function getConfig($key) { return $this->getConfigurationSourceList()->getConfig($key); } final public function canHandleSignal($signo) { return false; } final public function newCommand(PhutilExecutableFuture $future) { return id(new ArcanistCommand()) ->setLogEngine($this->getLogEngine()) ->setExecutableFuture($future); } }