diff --git a/src/error/PhutilErrorHandler.php b/src/error/PhutilErrorHandler.php
index 1434bb43..661e3164 100644
--- a/src/error/PhutilErrorHandler.php
+++ b/src/error/PhutilErrorHandler.php
@@ -1,612 +1,611 @@
 <?php
 
 /**
  * Improve PHP error logs and optionally route errors, exceptions and debugging
  * information to a central listener.
  *
  * This class takes over the PHP error and exception handlers when you call
  * ##PhutilErrorHandler::initialize()## and forwards all debugging information
  * to a listener you install with ##PhutilErrorHandler::setErrorListener()##.
  *
  * To use PhutilErrorHandler, which will enhance the messages printed to the
  * PHP error log, just initialize it:
  *
  *    PhutilErrorHandler::initialize();
  *
  * To additionally install a custom listener which can print error information
  * to some other file or console, register a listener:
  *
  *    PhutilErrorHandler::setErrorListener($some_callback);
  *
  * For information on writing an error listener, see
  * @{function:phutil_error_listener_example}. Providing a listener is optional,
  * you will benefit from improved error logs even without one.
  *
  * Phabricator uses this class to drive the DarkConsole "Error Log" plugin.
  *
  * @task config   Configuring Error Dispatch
  * @task exutil   Exception Utilities
  * @task trap     Error Traps
  * @task internal Internals
  */
 final class PhutilErrorHandler extends Phobject {
 
   private static $errorListener = null;
   private static $initialized = false;
   private static $traps = array();
 
   const EXCEPTION   = 'exception';
   const ERROR       = 'error';
   const PHLOG       = 'phlog';
   const DEPRECATED  = 'deprecated';
 
 
 /* -(  Configuring Error Dispatch  )----------------------------------------- */
 
 
   /**
    * Registers this class as the PHP error and exception handler. This will
    * overwrite any previous handlers!
    *
    * @return void
    * @task config
    */
   public static function initialize() {
     self::$initialized = true;
     set_error_handler(array(__CLASS__, 'handleError'));
     set_exception_handler(array(__CLASS__, 'handleException'));
   }
 
   /**
    * Provide an optional listener callback which will receive all errors,
    * exceptions and debugging messages. It can then print them to a web console,
    * for example.
    *
    * See @{function:phutil_error_listener_example} for details about the
    * callback parameters and operation.
    *
    * @return void
    * @task config
    */
   public static function setErrorListener($listener) {
     self::$errorListener = $listener;
   }
 
 
 /* -(  Exception Utilities  )------------------------------------------------ */
 
 
   /**
    * Gets the previous exception of a nested exception. Prior to PHP 5.3 you
    * can use @{class:PhutilProxyException} to nest exceptions; after PHP 5.3
    * all exceptions are nestable.
    *
    * @param   Exception|Throwable       Exception to unnest.
    * @return  Exception|Throwable|null  Previous exception, if one exists.
    * @task    exutil
    */
   public static function getPreviousException($ex) {
     if (method_exists($ex, 'getPrevious')) {
       return $ex->getPrevious();
     }
     if (method_exists($ex, 'getPreviousException')) {
       return $ex->getPreviousException();
     }
     return null;
   }
 
 
   /**
    * Find the most deeply nested exception from a possibly-nested exception.
    *
    * @param   Exception|Throwable     A possibly-nested exception.
    * @return  Exception|Throwable     Deepest exception in the nest.
    * @task    exutil
    */
   public static function getRootException($ex) {
     $root = $ex;
     while (self::getPreviousException($root)) {
       $root = self::getPreviousException($root);
     }
     return $root;
   }
 
 
 /* -(  Trapping Errors  )---------------------------------------------------- */
 
 
   /**
    * Adds an error trap. Normally you should not invoke this directly;
    * @{class:PhutilErrorTrap} registers itself on construction.
    *
    * @param PhutilErrorTrap Trap to add.
    * @return void
    * @task trap
    */
   public static function addErrorTrap(PhutilErrorTrap $trap) {
     $key = $trap->getTrapKey();
     self::$traps[$key] = $trap;
   }
 
 
   /**
    * Removes an error trap. Normally you should not invoke this directly;
    * @{class:PhutilErrorTrap} deregisters itself on destruction.
    *
    * @param PhutilErrorTrap Trap to remove.
    * @return void
    * @task trap
    */
   public static function removeErrorTrap(PhutilErrorTrap $trap) {
     $key = $trap->getTrapKey();
     unset(self::$traps[$key]);
   }
 
 
 /* -(  Internals  )---------------------------------------------------------- */
 
 
   /**
    * Determine if PhutilErrorHandler has been initialized.
    *
    * @return bool True if initialized.
    * @task internal
    */
   public static function hasInitialized() {
     return self::$initialized;
   }
 
 
   /**
    * Handles PHP errors and dispatches them forward. This is a callback for
    * ##set_error_handler()##. You should not call this function directly; use
    * @{function:phlog} to print debugging messages or ##trigger_error()## to
    * trigger PHP errors.
    *
    * This handler converts E_RECOVERABLE_ERROR messages from violated typehints
    * into @{class:InvalidArgumentException}s.
    *
    * This handler converts other E_RECOVERABLE_ERRORs into
    * @{class:RuntimeException}s.
    *
    * This handler converts E_NOTICE messages from uses of undefined variables
    * into @{class:RuntimeException}s.
    *
    * @param int Error code.
    * @param string Error message.
    * @param string File where the error occurred.
    * @param int Line on which the error occurred.
    * @param wild Error context information.
    * @return void
    * @task internal
    */
   public static function handleError($num, $str, $file, $line, $ctx = null) {
-
     foreach (self::$traps as $trap) {
       $trap->addError($num, $str, $file, $line);
     }
 
     if ((error_reporting() & $num) == 0) {
       // Respect the use of "@" to silence warnings: if this error was
       // emitted from a context where "@" was in effect, the
       // value returned by error_reporting() will be 0. This is the
       // recommended way to check for this, see set_error_handler() docs
       // on php.net.
       return false;
     }
 
     // See T13499. If this is a user error arising from "trigger_error()" or
     // similar, route it through normal error handling: this is probably the
     // best match to authorial intent, since the code could choose to throw
     // an exception instead if it wanted that behavior. Phabricator does not
     // use "trigger_error()" so we never normally expect to reach this
     // block in first-party code.
 
     if (($num === E_USER_ERROR) ||
         ($num === E_USER_WARNING) ||
         ($num === E_USER_NOTICE)) {
 
       $trace = debug_backtrace();
       array_shift($trace);
       self::dispatchErrorMessage(
         self::ERROR,
         $str,
         array(
           'file'       => $file,
           'line'       => $line,
           'error_code' => $num,
           'trace'      => $trace,
         ));
 
       return;
     }
 
     // Convert typehint failures into exceptions.
     if (preg_match('/^Argument (\d+) passed to (\S+) must be/', $str)) {
       throw new InvalidArgumentException($str);
     }
 
     // Convert other E_RECOVERABLE_ERRORs into generic runtime exceptions.
     if ($num == E_RECOVERABLE_ERROR) {
       throw new RuntimeException($str);
     }
 
     // Convert uses of undefined variables into exceptions.
     if (preg_match('/^Undefined variable: /', $str)) {
       throw new RuntimeException($str);
     }
 
     // Convert uses of undefined properties into exceptions.
     if (preg_match('/^Undefined property: /', $str)) {
       throw new RuntimeException($str);
     }
 
     // Convert undefined constants into exceptions. Usually this means there
     // is a missing `$` and the program is horribly broken.
     if (preg_match('/^Use of undefined constant /', $str)) {
       throw new RuntimeException($str);
     }
 
     // Convert undefined indexes into exceptions.
     if (preg_match('/^Undefined index: /', $str)) {
       throw new RuntimeException($str);
     }
 
     // Convert undefined offsets into exceptions.
     if (preg_match('/^Undefined offset: /', $str)) {
       throw new RuntimeException($str);
     }
 
     // See T13499. Convert all other runtime errors not handled in a more
     // specific way into runtime exceptions.
     throw new RuntimeException($str);
   }
 
   /**
    * Handles PHP exceptions and dispatches them forward. This is a callback for
    * ##set_exception_handler()##. You should not call this function directly;
    * to print exceptions, pass the exception object to @{function:phlog}.
    *
    * @param Exception|Throwable Uncaught exception object.
    * @return void
    * @task internal
    */
   public static function handleException($ex) {
     self::dispatchErrorMessage(
       self::EXCEPTION,
       $ex,
       array(
         'file'  => $ex->getFile(),
         'line'  => $ex->getLine(),
         'trace' => self::getExceptionTrace($ex),
         'catch_trace' => debug_backtrace(),
       ));
 
     // Normally, PHP exits with code 255 after an uncaught exception is thrown.
     // However, if we install an exception handler (as we have here), it exits
     // with code 0 instead. Script execution terminates after this function
     // exits in either case, so exit explicitly with the correct exit code.
     exit(255);
   }
 
 
   /**
    * Output a stacktrace to the PHP error log.
    *
    * @param trace A stacktrace, e.g. from debug_backtrace();
    * @return void
    * @task internal
    */
   public static function outputStacktrace($trace) {
     $lines = explode("\n", self::formatStacktrace($trace));
     foreach ($lines as $line) {
       error_log($line);
     }
   }
 
 
   /**
    * Format a stacktrace for output.
    *
    * @param trace A stacktrace, e.g. from debug_backtrace();
    * @return string Human-readable trace.
    * @task internal
    */
   public static function formatStacktrace($trace) {
     $result = array();
 
     $libinfo = self::getLibraryVersions();
     if ($libinfo) {
       foreach ($libinfo as $key => $dict) {
         $info = array();
         foreach ($dict as $dkey => $dval) {
           $info[] = $dkey.'='.$dval;
         }
         $libinfo[$key] = $key.'('.implode(', ', $info).')';
       }
       $result[] = implode(', ', $libinfo);
     }
 
     foreach ($trace as $key => $entry) {
       $line = '  #'.$key.' ';
       if (!empty($entry['xid'])) {
         if ($entry['xid'] != 1) {
           $line .= '<#'.$entry['xid'].'> ';
         }
       }
       if (isset($entry['class'])) {
         $line .= $entry['class'].'::';
       }
       $line .= idx($entry, 'function', '');
 
       if (isset($entry['args'])) {
         $args = array();
         foreach ($entry['args'] as $arg) {
 
           // NOTE: Print out object types, not values. Values sometimes contain
           // sensitive information and are usually not particularly helpful
           // for debugging.
 
           $type = (gettype($arg) == 'object')
             ? get_class($arg)
             : gettype($arg);
           $args[] = $type;
         }
         $line .= '('.implode(', ', $args).')';
       }
 
       if (isset($entry['file'])) {
         $file = self::adjustFilePath($entry['file']);
         $line .= ' called at ['.$file.':'.$entry['line'].']';
       }
 
       $result[] = $line;
     }
     return implode("\n", $result);
   }
 
 
   /**
    * All different types of error messages come here before they are
    * dispatched to the listener; this method also prints them to the PHP error
    * log.
    *
    * @param const Event type constant.
    * @param wild Event value.
    * @param dict Event metadata.
    * @return void
    * @task internal
    */
   public static function dispatchErrorMessage($event, $value, $metadata) {
-    $timestamp = strftime('%Y-%m-%d %H:%M:%S');
+    $timestamp = date('Y-m-d H:i:s');
 
     switch ($event) {
       case self::ERROR:
         $default_message = sprintf(
           '[%s] ERROR %d: %s at [%s:%d]',
           $timestamp,
           $metadata['error_code'],
           $value,
           $metadata['file'],
           $metadata['line']);
 
         $metadata['default_message'] = $default_message;
         error_log($default_message);
         self::outputStacktrace($metadata['trace']);
         break;
       case self::EXCEPTION:
         $messages = array();
         $current = $value;
         do {
           $messages[] = '('.get_class($current).') '.$current->getMessage();
         } while ($current = self::getPreviousException($current));
         $messages = implode(' {>} ', $messages);
 
         if (strlen($messages) > 4096) {
           $messages = substr($messages, 0, 4096).'...';
         }
 
         $default_message = sprintf(
           '[%s] EXCEPTION: %s at [%s:%d]',
           $timestamp,
           $messages,
           self::adjustFilePath(self::getRootException($value)->getFile()),
           self::getRootException($value)->getLine());
 
         $metadata['default_message'] = $default_message;
         error_log($default_message);
         self::outputStacktrace($metadata['trace']);
         break;
       case self::PHLOG:
         $default_message = sprintf(
           '[%s] PHLOG: %s at [%s:%d]',
           $timestamp,
           PhutilReadableSerializer::printShort($value),
           $metadata['file'],
           $metadata['line']);
 
         $metadata['default_message'] = $default_message;
         error_log($default_message);
         break;
       default:
         error_log(pht('Unknown event %s', $event));
         break;
     }
 
     if (self::$errorListener) {
       static $handling_error;
       if ($handling_error) {
         error_log(
           'Error handler was reentered, some errors were not passed to the '.
           'listener.');
         return;
       }
       $handling_error = true;
       call_user_func(self::$errorListener, $event, $value, $metadata);
       $handling_error = false;
     }
   }
 
   public static function adjustFilePath($path) {
     // Compute known library locations so we can emit relative paths if the
     // file resides inside a known library. This is a little cleaner to read,
     // and limits the number of false positives we get about full path
     // disclosure via HackerOne.
 
     $bootloader = PhutilBootloader::getInstance();
     $libraries = $bootloader->getAllLibraries();
     $roots = array();
     foreach ($libraries as $library) {
       $root = $bootloader->getLibraryRoot($library);
       // For these libraries, the effective root is one level up.
       switch ($library) {
         case 'arcanist':
         case 'phabricator':
           $root = dirname($root);
           break;
       }
 
       if (!strncmp($root, $path, strlen($root))) {
         return '<'.$library.'>'.substr($path, strlen($root));
       }
     }
 
     return $path;
   }
 
   public static function getLibraryVersions() {
     $libinfo = array();
 
     $bootloader = PhutilBootloader::getInstance();
     foreach ($bootloader->getAllLibraries() as $library) {
       $root = phutil_get_library_root($library);
       $try_paths = array(
         $root,
         dirname($root),
       );
       $libinfo[$library] = array();
 
       $get_refs = array('master');
       foreach ($try_paths as $try_path) {
         // Try to read what the HEAD of the repository is pointed at. This is
         // normally the name of a branch ("ref").
         $try_file = $try_path.'/.git/HEAD';
         if (@file_exists($try_file)) {
           $head = @file_get_contents($try_file);
           $matches = null;
           if (preg_match('(^ref: refs/heads/(.*)$)', trim($head), $matches)) {
             $libinfo[$library]['head'] = trim($matches[1]);
             $get_refs[] = trim($matches[1]);
           } else {
             $libinfo[$library]['head'] = trim($head);
           }
           break;
         }
       }
 
       // Try to read which commit relevant branch heads are at.
       foreach (array_unique($get_refs) as $ref) {
         foreach ($try_paths as $try_path) {
           $try_file = $try_path.'/.git/refs/heads/'.$ref;
           if (@file_exists($try_file)) {
             $hash = @file_get_contents($try_file);
             if ($hash) {
               $libinfo[$library]['ref.'.$ref] = substr(trim($hash), 0, 12);
               break;
             }
           }
         }
       }
 
       // Look for extension files.
       $custom = @scandir($root.'/extensions/');
       if ($custom) {
         $count = 0;
         foreach ($custom as $custom_path) {
           if (preg_match('/\.php$/', $custom_path)) {
             $count++;
           }
         }
         if ($count) {
           $libinfo[$library]['custom'] = $count;
         }
       }
     }
 
     ksort($libinfo);
 
     return $libinfo;
   }
 
   /**
    * Get a full trace across all proxied and aggregated exceptions.
    *
    * This attempts to build a set of stack frames which completely represent
    * all of the places an exception came from, even if it came from multiple
    * origins and has been aggregated or proxied.
    *
    * @param Exception|Throwable Exception to retrieve a trace for.
    * @return list<wild> List of stack frames.
    */
   public static function getExceptionTrace($ex) {
     $id = 1;
 
     // Keep track of discovered exceptions which we need to build traces for.
     $stack = array(
       array($id, $ex),
     );
 
     $frames = array();
     while ($info = array_shift($stack)) {
       list($xid, $ex) = $info;
 
       // We're going from top-level exception down in bredth-first order, but
       // want to build a trace in approximately standard order (deepest part of
       // the call stack to most shallow) so we need to reverse each list of
       // frames and then reverse everything at the end.
 
       $ex_frames = array_reverse($ex->getTrace());
       $ex_frames = array_values($ex_frames);
       $last_key = (count($ex_frames) - 1);
       foreach ($ex_frames as $frame_key => $frame) {
         $frame['xid'] = $xid;
 
         // If this is a child/previous exception and we're on the deepest frame
         // and missing file/line data, fill it in from the exception itself.
         if ($xid > 1 && ($frame_key == $last_key)) {
           if (empty($frame['file'])) {
             $frame['file'] = $ex->getFile();
             $frame['line'] = $ex->getLine();
           }
         }
 
         // Since the exceptions are likely to share the most shallow frames,
         // try to add those to the trace only once.
         if (isset($frame['file']) && isset($frame['line'])) {
           $signature = $frame['file'].':'.$frame['line'];
           if (empty($frames[$signature])) {
             $frames[$signature] = $frame;
           }
         } else {
           $frames[] = $frame;
         }
       }
 
       // If this is a proxy exception, add the proxied exception.
       $prev = self::getPreviousException($ex);
       if ($prev) {
         $stack[] = array(++$id, $prev);
       }
 
       // If this is an aggregate exception, add the child exceptions.
       if ($ex instanceof PhutilAggregateException) {
         foreach ($ex->getExceptions() as $child) {
           $stack[] = array(++$id, $child);
         }
       }
     }
 
     return array_values(array_reverse($frames));
   }
 
 }
diff --git a/src/filesystem/linesofalarge/LinesOfALarge.php b/src/filesystem/linesofalarge/LinesOfALarge.php
index 94496cdd..74a33b69 100644
--- a/src/filesystem/linesofalarge/LinesOfALarge.php
+++ b/src/filesystem/linesofalarge/LinesOfALarge.php
@@ -1,224 +1,224 @@
 <?php
 
 /**
  * Abstraction for processing large inputs without holding them in memory. This
  * class implements line-oriented, buffered reads of some external stream, where
  * a "line" is characterized by some delimiter character. This provides a
  * straightforward interface for most large-input tasks, with relatively good
  * performance.
  *
  * If your stream is not large, it is generally more efficient (and certainly
  * simpler) to read the entire stream first and then process it (e.g., with
  * `explode()`).
  *
  * This class is abstract. The concrete implementations available are:
  *
  *   - @{class:LinesOfALargeFile}, for reading large files; and
  *   - @{class:LinesOfALargeExecFuture}, for reading large output from
  *     subprocesses.
  *
  * For example:
  *
  *   foreach (new LinesOfALargeFile('/path/to/file.log') as $line) {
  *     // ...
  *   }
  *
  * By default, a line is delimited by "\n". The delimiting character is
  * not returned. You can change the character with @{method:setDelimiter}. The
  * last part of the file is returned as the last $line, even if it does not
  * include a terminating character (if it does, the terminating character is
  * stripped).
  *
  * @task  config    Configuration
  * @task  internals Internals
  * @task  iterator  Iterator Interface
  */
 abstract class LinesOfALarge extends Phobject implements Iterator {
 
   private $pos;
   private $buf;
   private $num;
   private $line;
   private $valid;
   private $eof;
 
   private $delimiter = "\n";
 
 
 /* -(  Configuration  )------------------------------------------------------ */
 
 
   /**
    * Change the "line" delimiter character, which defaults to "\n". This is
    * used to determine where each line ends.
    *
    * If you pass `null`, data will be read from source as it becomes available,
    * without looking for delimiters. You can use this to stream a large file or
    * the output of a command which returns a large amount of data.
    *
    * @param string|null A one-byte delimiter character.
    * @return this
    * @task config
    */
   final public function setDelimiter($character) {
     if (($character !== null) && (strlen($character) !== 1)) {
       throw new Exception(
         pht('Delimiter character must be one byte in length or null.'));
     }
     $this->delimiter = $character;
     return $this;
   }
 
 
 /* -(  Internals  )---------------------------------------------------------- */
 
 
   /**
    * Hook, called before @{method:rewind()}. Allows a concrete implementation
    * to open resources or reset state.
    *
    * @return void
    * @task internals
    */
   abstract protected function willRewind();
 
 
   /**
    * Called when the iterator needs more data. The subclass should return more
    * data, or empty string to indicate end-of-stream.
    *
    * @return string Data, or empty string for end-of-stream.
    * @task internals
    */
   abstract protected function readMore();
 
 
 /* -(  Iterator Interface  )------------------------------------------------- */
 
 
   /**
    * @task iterator
    */
   final public function rewind() {
     $this->willRewind();
 
     $this->buf = '';
     $this->pos = 0;
     $this->num = 0;
     $this->eof = false;
     $this->valid = true;
 
     $this->next();
   }
 
 
   /**
    * @task iterator
    */
   final public function key() {
     return $this->num;
   }
 
 
   /**
    * @task iterator
    */
   final public function current() {
     return $this->line;
   }
 
 
   /**
    * @task iterator
    */
   final public function valid() {
     return $this->valid;
   }
 
 
   /**
    * @task iterator
    */
   final public function next() {
     // Consume the stream a chunk at a time into an internal buffer, then
     // read lines out of that buffer. This gives us flexibility (stream sources
     // only need to be able to read blocks of bytes) and performance (we can
     // read in reasonably-sized chunks of many lines), at the cost of some
     // complexity in buffer management.
 
     // We do this in a loop to avoid recursion when consuming more bytes, in
     // case the size of a line is very large compared to the chunk size we
     // read.
     while (true) {
       if (strlen($this->buf)) {
 
         // If we don't have a delimiter, return the entire buffer.
         if ($this->delimiter === null) {
           $this->num++;
           $this->line = substr($this->buf, $this->pos);
           $this->buf = '';
           $this->pos = 0;
           return;
         }
 
         // If we already have some data buffered, try to get the next line from
         // the buffer. Search through the buffer for a delimiter. This should be
         // the common case.
         $endl = strpos($this->buf, $this->delimiter, $this->pos);
 
         if ($endl !== false) {
           // We found a delimiter, so return the line it delimits. We leave
           // the buffer as-is so we don't need to reallocate it, in case it is
           // large relative to the size of a line. Instead, we move our cursor
           // within the buffer forward.
           $this->num++;
           $this->line = substr($this->buf, $this->pos, ($endl - $this->pos));
           $this->pos = $endl + 1;
           return;
         }
 
         // We only have part of a line left in the buffer (no delimiter in the
         // remaining piece), so throw away the part we've already emitted and
         // continue below.
         $this->buf = substr($this->buf, $this->pos);
         $this->pos = 0;
       }
 
       // We weren't able to produce the next line from the bytes we already had
       // buffered, so read more bytes from the input stream.
 
       if ($this->eof) {
         // NOTE: We keep track of EOF (an empty read) so we don't make any more
         // reads afterward. Normally, we'll return from the first EOF read,
         // emit the line, and then next() will be called again. Without tracking
         // EOF, we'll attempt another read. A well-behaved implementation should
         // still return empty string, but we can protect against any issues
         // here by keeping a flag.
         $more = '';
       } else {
         $more = $this->readMore();
       }
 
       if (strlen($more)) {
         // We got some bytes, so add them to the buffer and then try again.
         $this->buf .= $more;
         continue;
       } else {
         // No more bytes. If we have a buffer, return its contents. We
         // potentially return part of a line here if the last line had no
         // delimiter, but that currently seems reasonable as a default
         // behavior. If we don't have a buffer, we're done.
         $this->eof = true;
         if (strlen($this->buf)) {
           $this->num++;
           $this->line = $this->buf;
-          $this->buf = null;
+          $this->buf = '';
         } else {
           $this->valid = false;
         }
         break;
       }
     }
   }
 
 }
diff --git a/src/future/exec/ExecFuture.php b/src/future/exec/ExecFuture.php
index ecce090c..7aadd61a 100644
--- a/src/future/exec/ExecFuture.php
+++ b/src/future/exec/ExecFuture.php
@@ -1,1017 +1,1036 @@
 <?php
 
 /**
  * Execute system commands in parallel using futures.
  *
  * ExecFuture is a future, which means it runs asynchronously and represents
  * a value which may not exist yet. See @{article:Using Futures} for an
  * explanation of futures. When an ExecFuture resolves, it returns the exit
  * code, stdout and stderr of the process it executed.
  *
  * ExecFuture is the core command execution implementation in libphutil, but is
  * exposed through a number of APIs. See @{article:Command Execution} for more
  * discussion about executing system commands.
  *
  * @task create   Creating ExecFutures
  * @task resolve  Resolving Execution
  * @task config   Configuring Execution
  * @task info     Command Information
  * @task interact Interacting With Commands
  * @task internal Internals
  */
 final class ExecFuture extends PhutilExecutableFuture {
 
   private $pipes        = array();
   private $proc         = null;
   private $start        = null;
   private $procStatus   = null;
 
   private $stdout       = null;
   private $stderr       = null;
   private $stdin        = null;
   private $closePipe    = true;
 
   private $stdoutPos    = 0;
   private $stderrPos    = 0;
 
   private $readBufferSize;
   private $stdoutSizeLimit = PHP_INT_MAX;
   private $stderrSizeLimit = PHP_INT_MAX;
 
   private $profilerCallID;
   private $killedByTimeout;
 
   private $windowsStdoutTempFile = null;
   private $windowsStderrTempFile = null;
 
   private $terminateTimeout;
   private $didTerminate;
   private $killTimeout;
 
   private static $descriptorSpec = array(
     0 => 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'];
   }
 
   public function hasPID() {
     if ($this->procStatus) {
       return true;
     }
 
     if ($this->proc) {
       return true;
     }
 
     return false;
   }
 
 
 /* -(  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);
+    $this->stderrPos = $this->getStderrBufferLength();
 
     return $result;
   }
 
   public function readStdout() {
     if ($this->start) {
       $this->updateFuture(); // Sync
     }
 
     $result = (string)substr($this->stdout, $this->stdoutPos);
-    $this->stdoutPos = strlen($this->stdout);
+    $this->stdoutPos = $this->getStdoutBufferLength();
+
     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.
    *
    * 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() {
     $result = $this->resolve();
     return $this->raiseResultError($result);
   }
 
   /**
    * 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() {
     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 <err, stdout, stderr> results.
    * @task resolve
    */
   public function resolveKill() {
     if (!$this->hasResult()) {
       $signal = 9;
 
       if ($this->proc) {
         proc_terminate($this->proc, $signal);
       }
 
       $this->closeProcess();
 
       $result = array(
         128 + $signal,
         $this->stdout,
         $this->stderr,
       );
 
       $this->recordResult($result);
     }
 
     return $this->getResult();
   }
 
   private function recordResult(array $result) {
     $resolve_on_error = $this->getResolveOnError();
     if (!$resolve_on_error) {
       $result = $this->raiseResultError($result);
     }
 
     $this->setResult($result);
   }
 
   private function raiseResultError($result) {
     list($err, $stdout, $stderr) = $result;
 
     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);
   }
 
 
 /* -(  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);
+    return !$this->getStdoutBufferLength();
   }
 
 
   /**
    * 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 a soft dependencies on PhutilErrorTrap here, to avoid
     // the need to build it into the Phage agent. Under normal circumstances,
     // this class are always available.
 
     if (!$this->pipes) {
       $is_windows = phutil_is_windows();
 
       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 ($err) {
           $err = $err['message'];
         }
       }
 
       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. See also T13504. Fail the
         // future immediately, acting as though it exited with an error code
         // for consistency with Linux.
 
         $result = array(
           1,
           '',
           pht(
             'Call to "proc_open()" to open a subprocess failed: %s',
             $err),
         );
 
         $this->recordResult($result);
 
         return true;
       }
 
       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();
 
       try {
         $bytes = fwrite($stdin, $write_segment);
       } catch (RuntimeException $ex) {
         // If the subprocess has exited, we may get a broken pipe error here
         // in recent versions of PHP. There does not seem to be any way to
         // get the actual error code other than reading the exception string.
 
         // For now, treat this as if writes are blocked.
         break;
       }
 
       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);
+      $stdout_len = $this->getStdoutBufferLength();
+      $stderr_len = $this->getStderrBufferLength();
+
+      $max_stdout_read_bytes = $read_buffer_size - $stdout_len;
+      $max_stderr_read_bytes = $read_buffer_size - $stderr_len;
     }
 
     if ($max_stdout_read_bytes > 0) {
       $this->stdout .= $this->readAndDiscard(
         $stdout,
-        $this->getStdoutSizeLimit() - strlen($this->stdout),
+        $this->getStdoutSizeLimit() - $this->getStdoutBufferLength(),
         'stdout',
         $max_stdout_read_bytes);
     }
 
     if ($max_stderr_read_bytes > 0) {
       $this->stderr .= $this->readAndDiscard(
         $stderr,
-        $this->getStderrSizeLimit() - strlen($this->stderr),
+        $this->getStderrSizeLimit() - $this->getStderrBufferLength(),
         '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) {
       $signal_info = null;
 
       // 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']) {
           $signo = $status['termsig'];
 
           $err = 128 + $signo;
 
           $signal_info = pht(
             "<Process was terminated by signal %s (%d).>\n\n",
             phutil_get_signal_name($signo),
             $signo);
         }
       }
 
       $result = array(
         $err,
         $this->stdout,
         $signal_info.$this->stderr,
       );
 
       $this->recordResult($result);
 
       $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);
   }
 
 
   /**
    * 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;
       }
     }
 
     // See T13555. This may occur if you call "getPID()" on a future which
     // exited immediately without ever creating a valid subprocess.
 
     if (!$this->proc) {
       throw new Exception(
         pht(
           'Attempting to get subprocess status in "ExecFuture" with no '.
           'valid subprocess.'));
     }
 
     $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;
       }
     }
   }
 
   protected function getServiceProfilerStartParameters() {
     return array(
       'type' => 'exec',
       'command' => phutil_string_cast($this->getCommand()),
     );
   }
 
   protected function getServiceProfilerResultParameters() {
     if ($this->hasResult()) {
       $result = $this->getResult();
       $err = idx($result, 0);
     } else {
       $err = null;
     }
 
     return array(
       'err' => $err,
     );
   }
 
+  private function getStdoutBufferLength() {
+    if ($this->stdout === null) {
+      return 0;
+    }
+
+    return strlen($this->stdout);
+  }
+
+  private function getStderrBufferLength() {
+    if ($this->stderr === null) {
+      return 0;
+    }
+
+    return strlen($this->stderr);
+  }
 
 }
diff --git a/src/future/http/BaseHTTPFuture.php b/src/future/http/BaseHTTPFuture.php
index 14562ebe..1def346b 100644
--- a/src/future/http/BaseHTTPFuture.php
+++ b/src/future/http/BaseHTTPFuture.php
@@ -1,458 +1,460 @@
 <?php
 
 /**
  * Execute HTTP requests with a future-oriented API. For example:
  *
  *   $future = new HTTPFuture('http://www.example.com/');
  *   list($status, $body, $headers) = $future->resolve();
  *
  * This is an abstract base class which defines the API that HTTP futures
  * conform to. Concrete implementations are available in @{class:HTTPFuture}
  * and @{class:HTTPSFuture}. All futures return a <status, body, header> tuple
  * when resolved; status is an object of class @{class:HTTPFutureResponseStatus}
  * and may represent any of a wide variety of errors in the transport layer,
  * a support library, or the actual HTTP exchange.
  *
  * @task create Creating a New Request
  * @task config Configuring the Request
  * @task resolve Resolving the Request
  * @task internal Internals
  */
 abstract class BaseHTTPFuture extends Future {
 
   private $method   = 'GET';
   private $timeout  = 300.0;
   private $headers  = array();
   private $uri;
   private $data;
   private $expect;
   private $disableContentDecoding;
 
 /* -(  Creating a New Request  )--------------------------------------------- */
 
 
   /**
    * Build a new future which will make an HTTP request to a given URI, with
    * some optional data payload. Since this class is abstract you can't actually
    * instantiate it; instead, build a new @{class:HTTPFuture} or
    * @{class:HTTPSFuture}.
    *
    * @param string Fully-qualified URI to send a request to.
    * @param mixed  String or array to include in the request. Strings will be
    *               transmitted raw; arrays will be encoded and sent as
    *               'application/x-www-form-urlencoded'.
    * @task create
    */
   final public function __construct($uri, $data = array()) {
     $this->setURI((string)$uri);
     $this->setData($data);
   }
 
 
 /* -(  Configuring the Request  )-------------------------------------------- */
 
 
   /**
    * Set a timeout for the service call. If the request hasn't resolved yet,
    * the future will resolve with a status that indicates the request timed
    * out. You can determine if a status is a timeout status by calling
    * isTimeout() on the status object.
    *
    * @param float Maximum timeout, in seconds.
    * @return this
    * @task config
    */
   public function setTimeout($timeout) {
     $this->timeout = $timeout;
     return $this;
   }
 
 
   /**
    * Get the currently configured timeout.
    *
    * @return float Maximum number of seconds the request will execute for.
    * @task config
    */
   public function getTimeout() {
     return $this->timeout;
   }
 
 
   /**
    * Select the HTTP method (e.g., "GET", "POST", "PUT") to use for the request.
    * By default, requests use "GET".
    *
    * @param string HTTP method name.
    * @return this
    * @task config
    */
   final public function setMethod($method) {
     static $supported_methods = array(
       'GET'     => true,
       'POST'    => true,
       'PUT'     => true,
       'DELETE'  => true,
     );
 
     if (empty($supported_methods[$method])) {
       throw new Exception(
         pht(
           "The HTTP method '%s' is not supported. Supported HTTP methods ".
           "are: %s.",
           $method,
           implode(', ', array_keys($supported_methods))));
     }
 
     $this->method = $method;
     return $this;
   }
 
 
   /**
    * Get the HTTP method the request will use.
    *
    * @return string HTTP method name, like "GET".
    * @task config
    */
   final public function getMethod() {
     return $this->method;
   }
 
 
   /**
    * Set the URI to send the request to. Note that this is also a constructor
    * parameter.
    *
    * @param string URI to send the request to.
    * @return this
    * @task config
    */
   public function setURI($uri) {
     $this->uri = (string)$uri;
     return $this;
   }
 
 
   /**
    * Get the fully-qualified URI the request will be made to.
    *
    * @return string URI the request will be sent to.
    * @task config
    */
   public function getURI() {
     return $this->uri;
   }
 
 
   /**
    * Provide data to send along with the request. Note that this is also a
    * constructor parameter; it may be more convenient to provide it there. Data
    * must be a string (in which case it will be sent raw) or an array (in which
    * case it will be encoded and sent as 'application/x-www-form-urlencoded').
    *
    * @param mixed Data to send with the request.
    * @return this
    * @task config
    */
   public function setData($data) {
     if (!is_string($data) && !is_array($data)) {
       throw new Exception(pht('Data parameter must be an array or string.'));
     }
     $this->data = $data;
     return $this;
   }
 
 
   /**
    * Get the data which will be sent with the request.
    *
    * @return mixed Data which will be sent.
    * @task config
    */
   public function getData() {
     return $this->data;
   }
 
 
   /**
    * Add an HTTP header to the request. The same header name can be specified
    * more than once, which will cause multiple headers to be sent.
    *
    * @param string Header name, like "Accept-Language".
    * @param string Header value, like "en-us".
    * @return this
    * @task config
    */
   public function addHeader($name, $value) {
     $this->headers[] = array($name, $value);
     return $this;
   }
 
 
   /**
    * Get headers which will be sent with the request. Optionally, you can
    * provide a filter, which will return only headers with that name. For
    * example:
    *
    *   $all_headers = $future->getHeaders();
    *   $just_user_agent = $future->getHeaders('User-Agent');
    *
    * In either case, an array with all (or all matching) headers is returned.
    *
    * @param string|null Optional filter, which selects only headers with that
    *                    name if provided.
    * @return array      List of all (or all matching) headers.
    * @task config
    */
   public function getHeaders($filter = null) {
-    $filter = strtolower($filter);
+    if ($filter !== null) {
+      $filter = phutil_utf8_strtolower($filter);
+    }
 
     $result = array();
     foreach ($this->headers as $header) {
       list($name, $value) = $header;
-      if (!$filter || ($filter == strtolower($name))) {
+      if (($filter === null) || ($filter === phutil_utf8_strtolower($name))) {
         $result[] = $header;
       }
     }
 
     return $result;
   }
 
   /**
    * Set the status codes that are expected in the response.
    * If set, isError on the status object will return true for status codes
    * that are not in the input array. Otherwise, isError will be true for any
    * HTTP status code outside the 2xx range (notwithstanding other errors such
    * as connection or transport issues).
    *
    * @param array|null List of expected HTTP status codes.
    *
    * @return this
    * @task config
    */
   public function setExpectStatus($status_codes) {
     $this->expect = $status_codes;
     return $this;
   }
 
   /**
    * Return list of expected status codes, or null if not set.
    *
    * @return array|null List of expected status codes.
    */
   public function getExpectStatus() {
     return $this->expect;
   }
 
 
   /**
    * Add a HTTP basic authentication header to the request.
    *
    * @param string                Username to authenticate with.
    * @param PhutilOpaqueEnvelope  Password to authenticate with.
    * @return this
    * @task config
    */
   public function setHTTPBasicAuthCredentials(
     $username,
     PhutilOpaqueEnvelope $password) {
 
     $password_plaintext = $password->openEnvelope();
     $credentials = base64_encode($username.':'.$password_plaintext);
 
     return $this->addHeader('Authorization', 'Basic '.$credentials);
   }
 
   public function getHTTPRequestByteLength() {
     // NOTE: This isn't very accurate, but it's only used by the "--trace"
     // call profiler to help pick out huge requests.
     $data = $this->getData();
 
     if (is_scalar($data)) {
       return strlen($data);
     }
 
     return strlen(phutil_build_http_querystring($data));
   }
 
   public function setDisableContentDecoding($disable_decoding) {
     $this->disableContentDecoding = $disable_decoding;
     return $this;
   }
 
   public function getDisableContentDecoding() {
     return $this->disableContentDecoding;
   }
 
 
 /* -(  Resolving the Request  )---------------------------------------------- */
 
 
   /**
    * Exception-oriented @{method:resolve}. Throws if the status indicates an
    * error occurred.
    *
    * @return tuple  HTTP request result <body, headers> tuple.
    * @task resolve
    */
   final public function resolvex() {
     $result = $this->resolve();
 
     list($status, $body, $headers) = $result;
     if ($status->isError()) {
       throw $status;
     }
 
     return array($body, $headers);
   }
 
 
 /* -(  Internals  )---------------------------------------------------------- */
 
 
   /**
    * Parse a raw HTTP response into a <status, body, headers> tuple.
    *
    * @param string Raw HTTP response.
    * @return tuple Valid resolution tuple.
    * @task internal
    */
   protected function parseRawHTTPResponse($raw_response) {
     $rex_base = "@^(?P<head>.*?)\r?\n\r?\n(?P<body>.*)$@s";
     $rex_head = "@^HTTP/\S+ (?P<code>\d+) ?(?P<status>.*?)".
                 "(?:\r?\n(?P<headers>.*))?$@s";
 
     // We need to parse one or more header blocks in case we got any
     // "HTTP/1.X 100 Continue" nonsense back as part of the response. This
     // happens with HTTPS requests, at the least.
     $response = $raw_response;
     while (true) {
       $matches = null;
       if (!preg_match($rex_base, $response, $matches)) {
         return $this->buildMalformedResult($raw_response);
       }
 
       $head = $matches['head'];
       $body = $matches['body'];
 
       if (!preg_match($rex_head, $head, $matches)) {
         return $this->buildMalformedResult($raw_response);
       }
 
       $response_code = (int)$matches['code'];
       $response_status = strtolower($matches['status']);
       if ($response_code == 100) {
         // This is HTTP/1.X 100 Continue, so this whole chunk is moot.
         $response = $body;
       } else if (($response_code == 200) &&
                  ($response_status == 'connection established')) {
         // When tunneling through an HTTPS proxy, we get an initial header
         // block like "HTTP/1.X 200 Connection established", then newlines,
         // then the normal response. Drop this chunk.
         $response = $body;
       } else {
         $headers = $this->parseHeaders(idx($matches, 'headers'));
         break;
       }
     }
 
     if (!$this->getDisableContentDecoding()) {
       $content_encoding = null;
       foreach ($headers as $header) {
         list($name, $value) = $header;
         $name = phutil_utf8_strtolower($name);
         if (!strcasecmp($name, 'Content-Encoding')) {
           $content_encoding = $value;
           break;
         }
       }
 
       switch ($content_encoding) {
         case 'gzip':
           $decoded_body = @gzdecode($body);
           if ($decoded_body === false) {
             return $this->buildMalformedResult($raw_response);
           }
           $body = $decoded_body;
           break;
       }
     }
 
     $status = new HTTPFutureHTTPResponseStatus(
       $response_code,
       $body,
       $headers,
       $this->expect);
 
     return array($status, $body, $headers);
   }
 
   /**
    * Parse an HTTP header block.
    *
    * @param string Raw HTTP headers.
    * @return list List of HTTP header tuples.
    * @task internal
    */
   protected function parseHeaders($head_raw) {
     $rex_header = '@^(?P<name>.*?):\s*(?P<value>.*)$@';
 
     $headers = array();
 
     if (!$head_raw) {
       return $headers;
     }
 
     $headers_raw = preg_split("/\r?\n/", $head_raw);
     foreach ($headers_raw as $header) {
       $m = null;
       if (preg_match($rex_header, $header, $m)) {
         $headers[] = array($m['name'], $m['value']);
       } else {
         $headers[] = array($header, null);
       }
     }
 
     return $headers;
   }
 
 
   /**
    * Find value of the first header with given name.
    *
    * @param list List of headers from `resolve()`.
    * @param string Case insensitive header name.
    * @return string Value of the header or null if not found.
    * @task resolve
    */
   public static function getHeader(array $headers, $search) {
     assert_instances_of($headers, 'array');
     foreach ($headers as $header) {
       list($name, $value) = $header;
       if (strcasecmp($name, $search) == 0) {
         return $value;
       }
     }
     return null;
   }
 
 
   /**
    * Build a result tuple indicating a parse error resulting from a malformed
    * HTTP response.
    *
    * @return tuple Valid resolution tuple.
    * @task internal
    */
   protected function buildMalformedResult($raw_response) {
     $body = null;
     $headers = array();
 
     $status = new HTTPFutureParseResponseStatus(
       HTTPFutureParseResponseStatus::ERROR_MALFORMED_RESPONSE,
       $raw_response);
     return array($status, $body, $headers);
   }
 
 }
diff --git a/src/future/http/HTTPSFuture.php b/src/future/http/HTTPSFuture.php
index c217c112..48824fb1 100644
--- a/src/future/http/HTTPSFuture.php
+++ b/src/future/http/HTTPSFuture.php
@@ -1,878 +1,878 @@
 <?php
 
 /**
  * Very basic HTTPS future.
  */
 final class HTTPSFuture extends BaseHTTPFuture {
 
   private static $multi;
   private static $results = array();
   private static $pool = array();
   private static $globalCABundle;
 
   private $handle;
   private $profilerCallID;
   private $cabundle;
   private $followLocation = true;
   private $responseBuffer = '';
   private $responseBufferPos;
   private $files = array();
   private $temporaryFiles = array();
   private $rawBody;
   private $rawBodyPos = 0;
   private $fileHandle;
 
   private $downloadPath;
   private $downloadHandle;
   private $parser;
   private $progressSink;
 
   private $curlOptions = array();
 
   /**
    * Create a temp file containing an SSL cert, and use it for this session.
    *
    * This allows us to do host-specific SSL certificates in whatever client
    * is using libphutil. e.g. in Arcanist, you could add an "ssl_cert" key
    * to a specific host in ~/.arcrc and use that.
    *
    * cURL needs this to be a file, it doesn't seem to be able to handle a string
    * which contains the cert. So we make a temporary file and store it there.
    *
    * @param string The multi-line, possibly lengthy, SSL certificate to use.
    * @return this
    */
   public function setCABundleFromString($certificate) {
     $temp = new TempFile();
     Filesystem::writeFile($temp, $certificate);
     $this->cabundle = $temp;
     return $this;
   }
 
   /**
    * Set the SSL certificate to use for this session, given a path.
    *
    * @param string The path to a valid SSL certificate for this session
    * @return this
    */
   public function setCABundleFromPath($path) {
     $this->cabundle = $path;
     return $this;
   }
 
   /**
    * Get the path to the SSL certificate for this session.
    *
    * @return string|null
    */
   public function getCABundle() {
     return $this->cabundle;
   }
 
   /**
    * Set whether Location headers in the response will be respected.
    * The default is true.
    *
    * @param boolean true to follow any Location header present in the response,
    *                false to return the request directly
    * @return this
    */
   public function setFollowLocation($follow) {
     $this->followLocation = $follow;
     return $this;
   }
 
   /**
    * Get whether Location headers in the response will be respected.
    *
    * @return boolean
    */
   public function getFollowLocation() {
     return $this->followLocation;
   }
 
   /**
    * Set the fallback CA certificate if one is not specified
    * for the session, given a path.
    *
    * @param string The path to a valid SSL certificate
    * @return void
    */
   public static function setGlobalCABundleFromPath($path) {
     self::$globalCABundle = $path;
   }
   /**
    * Set the fallback CA certificate if one is not specified
    * for the session, given a string.
    *
    * @param string The certificate
    * @return void
    */
   public static function setGlobalCABundleFromString($certificate) {
     $temp = new TempFile();
     Filesystem::writeFile($temp, $certificate);
     self::$globalCABundle = $temp;
   }
 
   /**
    * Get the fallback global CA certificate
    *
    * @return string
    */
   public static function getGlobalCABundle() {
     return self::$globalCABundle;
   }
 
   /**
    * Load contents of remote URI. Behaves pretty much like
    * `@file_get_contents($uri)` but doesn't require `allow_url_fopen`.
    *
    * @param string
    * @param float
    * @return string|false
    */
   public static function loadContent($uri, $timeout = null) {
     $future = new self($uri);
     if ($timeout !== null) {
       $future->setTimeout($timeout);
     }
     try {
       list($body) = $future->resolvex();
       return $body;
     } catch (HTTPFutureResponseStatus $ex) {
       return false;
     }
   }
 
   public function setDownloadPath($download_path) {
     $this->downloadPath = $download_path;
 
     if (Filesystem::pathExists($download_path)) {
       throw new Exception(
         pht(
           'Specified download path "%s" already exists, refusing to '.
           'overwrite.',
           $download_path));
     }
 
     return $this;
   }
 
   public function setProgressSink(PhutilProgressSink $progress_sink) {
     $this->progressSink = $progress_sink;
     return $this;
   }
 
   public function getProgressSink() {
     return $this->progressSink;
   }
 
   /**
    * See T13533. This supports an install-specific Kerberos workflow.
    */
   public function addCURLOption($option_key, $option_value) {
     if (!is_scalar($option_key)) {
       throw new Exception(
         pht(
           'Expected option key passed to "addCurlOption(<key>, ...)" to be '.
           'a scalar, got "%s".',
           phutil_describe_type($option_key)));
     }
 
     $this->curlOptions[] = array($option_key, $option_value);
     return $this;
   }
 
   /**
    * Attach a file to the request.
    *
    * @param string  HTTP parameter name.
    * @param string  File content.
    * @param string  File name.
    * @param string  File mime type.
    * @return this
    */
   public function attachFileData($key, $data, $name, $mime_type) {
     if (isset($this->files[$key])) {
       throw new Exception(
         pht(
           '%s currently supports only one file attachment for each '.
           'parameter name. You are trying to attach two different files with '.
           'the same parameter, "%s".',
           __CLASS__,
           $key));
     }
 
     $this->files[$key] = array(
       'data' => $data,
       'name' => $name,
       'mime' => $mime_type,
     );
 
     return $this;
   }
 
   public function isReady() {
     if ($this->hasResult()) {
       return true;
     }
 
     $uri = $this->getURI();
     $domain = id(new PhutilURI($uri))->getDomain();
 
     $is_download = $this->isDownload();
 
     // See T13396. For now, use the streaming response parser only if we're
     // downloading the response to disk.
     $use_streaming_parser = (bool)$is_download;
 
     if (!$this->handle) {
       $uri_object = new PhutilURI($uri);
       $proxy = PhutilHTTPEngineExtension::buildHTTPProxyURI($uri_object);
 
       // TODO: Currently, the "proxy" is not passed to the ServiceProfiler
       // because of changes to how ServiceProfiler is integrated. It would
       // be nice to pass it again.
 
       if (!self::$multi) {
         self::$multi = curl_multi_init();
         if (!self::$multi) {
           throw new Exception(pht('%s failed!', 'curl_multi_init()'));
         }
       }
 
       if (!empty(self::$pool[$domain])) {
         $curl = array_pop(self::$pool[$domain]);
       } else {
         $curl = curl_init();
         if (!$curl) {
           throw new Exception(pht('%s failed!', 'curl_init()'));
         }
       }
 
       $this->handle = $curl;
       curl_multi_add_handle(self::$multi, $curl);
 
       curl_setopt($curl, CURLOPT_URL, $uri);
 
       if (defined('CURLOPT_PROTOCOLS')) {
         // cURL supports a lot of protocols, and by default it will honor
         // redirects across protocols (for instance, from HTTP to POP3). Beyond
         // being very silly, this also has security implications:
         //
         //   http://blog.volema.com/curl-rce.html
         //
         // Disable all protocols other than HTTP and HTTPS.
 
         $allowed_protocols = CURLPROTO_HTTPS | CURLPROTO_HTTP;
         curl_setopt($curl, CURLOPT_PROTOCOLS, $allowed_protocols);
         curl_setopt($curl, CURLOPT_REDIR_PROTOCOLS, $allowed_protocols);
       }
 
-      if (strlen($this->rawBody)) {
+      if ($this->rawBody !== null) {
         if ($this->getData()) {
           throw new Exception(
             pht(
               'You can not execute an HTTP future with both a raw request '.
               'body and structured request data.'));
         }
 
         // We aren't actually going to use this file handle, since we are
         // just pushing data through the callback, but cURL gets upset if
         // we don't hand it a real file handle.
         $tmp = new TempFile();
         $this->fileHandle = fopen($tmp, 'r');
 
         // NOTE: We must set CURLOPT_PUT here to make cURL use CURLOPT_INFILE.
         // We'll possibly overwrite the method later on, unless this is really
         // a PUT request.
         curl_setopt($curl, CURLOPT_PUT, true);
         curl_setopt($curl, CURLOPT_INFILE, $this->fileHandle);
         curl_setopt($curl, CURLOPT_INFILESIZE, strlen($this->rawBody));
         curl_setopt($curl, CURLOPT_READFUNCTION,
           array($this, 'willWriteBody'));
       } else {
         $data = $this->formatRequestDataForCURL();
         curl_setopt($curl, CURLOPT_POSTFIELDS, $data);
       }
 
       $headers = $this->getHeaders();
 
       $saw_expect = false;
       $saw_accept = false;
       for ($ii = 0; $ii < count($headers); $ii++) {
         list($name, $value) = $headers[$ii];
         $headers[$ii] = $name.': '.$value;
         if (!strcasecmp($name, 'Expect')) {
           $saw_expect = true;
         }
         if (!strcasecmp($name, 'Accept-Encoding')) {
           $saw_accept = true;
         }
       }
       if (!$saw_expect) {
         // cURL sends an "Expect" header by default for certain requests. While
         // there is some reasoning behind this, it causes a practical problem
         // in that lighttpd servers reject these requests with a 417. Both sides
         // are locked in an eternal struggle (lighttpd has introduced a
         // 'server.reject-expect-100-with-417' option to deal with this case).
         //
         // The ostensibly correct way to suppress this behavior on the cURL side
         // is to add an empty "Expect:" header. If we haven't seen some other
         // explicit "Expect:" header, do so.
         //
         // See here, for example, although this issue is fairly widespread:
         //   http://curl.haxx.se/mail/archive-2009-07/0008.html
         $headers[] = 'Expect:';
       }
 
       if (!$saw_accept) {
         if (!$use_streaming_parser) {
           if ($this->canAcceptGzip()) {
             $headers[] = 'Accept-Encoding: gzip';
           }
         }
       }
 
       curl_setopt($curl, CURLOPT_HTTPHEADER, $headers);
 
       // Set the requested HTTP method, e.g. GET / POST / PUT.
       curl_setopt($curl, CURLOPT_CUSTOMREQUEST, $this->getMethod());
 
       // Make sure we get the headers and data back.
       curl_setopt($curl, CURLOPT_HEADER, true);
       curl_setopt($curl, CURLOPT_WRITEFUNCTION,
         array($this, 'didReceiveDataCallback'));
 
       if ($this->followLocation) {
         curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true);
         curl_setopt($curl, CURLOPT_MAXREDIRS, 20);
       }
 
       if (defined('CURLOPT_TIMEOUT_MS')) {
         // If CURLOPT_TIMEOUT_MS is available, use the higher-precision timeout.
         $timeout = max(1, ceil(1000 * $this->getTimeout()));
         curl_setopt($curl, CURLOPT_TIMEOUT_MS, $timeout);
       } else {
         // Otherwise, fall back to the lower-precision timeout.
         $timeout = max(1, ceil($this->getTimeout()));
         curl_setopt($curl, CURLOPT_TIMEOUT, $timeout);
       }
 
       // We're going to try to set CAINFO below. This doesn't work at all on
       // OSX around Yosemite (see T5913). On these systems, we'll use the
       // system CA and then try to tell the user that their settings were
       // ignored and how to fix things if we encounter a CA-related error.
       // Assume we have custom CA settings to start with; we'll clear this
       // flag if we read the default CA info below.
 
       // Try some decent fallbacks here:
       // - First, check if a bundle is set explicitly for this request, via
       //   `setCABundle()` or similar.
       // - Then, check if a global bundle is set explicitly for all requests,
       //   via `setGlobalCABundle()` or similar.
       // - Then, if a local custom.pem exists, use that, because it probably
       //   means that the user wants to override everything (also because the
       //   user might not have access to change the box's php.ini to add
       //   curl.cainfo).
       // - Otherwise, try using curl.cainfo. If it's set explicitly, it's
       //   probably reasonable to try using it before we fall back to what
       //   libphutil ships with.
       // - Lastly, try the default that libphutil ships with. If it doesn't
       //   work, give up and yell at the user.
 
       if (!$this->getCABundle()) {
         $caroot = dirname(phutil_get_library_root('arcanist'));
         $caroot = $caroot.'/resources/ssl/';
 
         $ini_val = ini_get('curl.cainfo');
         if (self::getGlobalCABundle()) {
           $this->setCABundleFromPath(self::getGlobalCABundle());
         } else if (Filesystem::pathExists($caroot.'custom.pem')) {
           $this->setCABundleFromPath($caroot.'custom.pem');
         } else if ($ini_val) {
           // TODO: We can probably do a pathExists() here, even.
           $this->setCABundleFromPath($ini_val);
         } else {
           $this->setCABundleFromPath($caroot.'default.pem');
         }
       }
 
       if ($this->canSetCAInfo()) {
         curl_setopt($curl, CURLOPT_CAINFO, $this->getCABundle());
       }
 
       $verify_peer = 1;
       $verify_host = 2;
 
       $extensions = PhutilHTTPEngineExtension::getAllExtensions();
       foreach ($extensions as $extension) {
         if ($extension->shouldTrustAnySSLAuthorityForURI($uri_object)) {
           $verify_peer = 0;
         }
         if ($extension->shouldTrustAnySSLHostnameForURI($uri_object)) {
           $verify_host = 0;
         }
       }
 
       curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, $verify_peer);
       curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, $verify_host);
       curl_setopt($curl, CURLOPT_SSLVERSION, 0);
 
       // See T13391. Recent versions of cURL default to "HTTP/2" on some
       // connections, but do not support HTTP/2 proxies. Until HTTP/2
       // stabilizes, force HTTP/1.1 explicitly.
       curl_setopt($curl, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
 
       if ($proxy) {
         curl_setopt($curl, CURLOPT_PROXY, (string)$proxy);
       }
 
       foreach ($this->curlOptions as $curl_option) {
         list($curl_key, $curl_value) = $curl_option;
         try {
           $ok = curl_setopt($curl, $curl_key, $curl_value);
           if (!$ok) {
             throw new Exception(
               pht(
                 'Call to "curl_setopt(...)" returned "false".'));
           }
         } catch (Exception $ex) {
           throw new PhutilProxyException(
             pht(
               'Call to "curl_setopt(...) failed for option key "%s".',
               $curl_key),
             $ex);
         }
       }
 
       if ($is_download) {
         $this->downloadHandle = @fopen($this->downloadPath, 'wb+');
         if (!$this->downloadHandle) {
           throw new Exception(
             pht(
               'Failed to open filesystem path "%s" for writing.',
               $this->downloadPath));
         }
       }
 
       if ($use_streaming_parser) {
         $streaming_parser = id(new PhutilHTTPResponseParser())
           ->setFollowLocationHeaders($this->getFollowLocation());
 
         if ($this->downloadHandle) {
           $streaming_parser->setWriteHandle($this->downloadHandle);
         }
 
         $progress_sink = $this->getProgressSink();
         if ($progress_sink) {
           $streaming_parser->setProgressSink($progress_sink);
         }
 
         $this->parser = $streaming_parser;
       }
     } else {
       $curl = $this->handle;
 
       if (!self::$results) {
         // NOTE: In curl_multi_select(), PHP calls curl_multi_fdset() but does
         // not check the return value of &maxfd for -1 until recent versions
         // of PHP (5.4.8 and newer). cURL may return -1 as maxfd in some unusual
         // situations; if it does, PHP enters select() with nfds=0, which blocks
         // until the timeout is reached.
         //
         // We could try to guess whether this will happen or not by examining
         // the version identifier, but we can also just sleep for only a short
         // period of time.
         curl_multi_select(self::$multi, 0.01);
       }
     }
 
     do {
       $active = null;
       $result = curl_multi_exec(self::$multi, $active);
     } while ($result == CURLM_CALL_MULTI_PERFORM);
 
     while ($info = curl_multi_info_read(self::$multi)) {
       if ($info['msg'] == CURLMSG_DONE) {
         self::$results[(int)$info['handle']] = $info;
       }
     }
 
     if (!array_key_exists((int)$curl, self::$results)) {
       return false;
     }
 
     // The request is complete, so release any temporary files we wrote
     // earlier.
     $this->temporaryFiles = array();
 
     $info = self::$results[(int)$curl];
     $result = $this->responseBuffer;
     $err_code = $info['result'];
 
     if ($err_code) {
       if (($err_code == CURLE_SSL_CACERT) && !$this->canSetCAInfo()) {
         $status = new HTTPFutureCertificateResponseStatus(
           HTTPFutureCertificateResponseStatus::ERROR_IMMUTABLE_CERTIFICATES,
           $uri);
       } else {
         $status = new HTTPFutureCURLResponseStatus($err_code, $uri);
       }
 
       $body = null;
       $headers = array();
       $this->setResult(array($status, $body, $headers));
     } else if ($this->parser) {
       $streaming_parser = $this->parser;
       try {
         $responses = $streaming_parser->getResponses();
         $final_response = last($responses);
         $result = array(
           $final_response->getStatus(),
           $final_response->getBody(),
           $final_response->getHeaders(),
         );
       } catch (HTTPFutureParseResponseStatus $ex) {
         $result = array($ex, null, array());
       }
 
       $this->setResult($result);
     } else {
       // cURL returns headers of all redirects, we strip all but the final one.
       $redirects = curl_getinfo($curl, CURLINFO_REDIRECT_COUNT);
       $result = preg_replace('/^(.*\r\n\r\n){'.$redirects.'}/sU', '', $result);
       $this->setResult($this->parseRawHTTPResponse($result));
     }
 
     curl_multi_remove_handle(self::$multi, $curl);
     unset(self::$results[(int)$curl]);
 
     // NOTE: We want to use keepalive if possible. Return the handle to a
     // pool for the domain; don't close it.
     if ($this->shouldReuseHandles()) {
       self::$pool[$domain][] = $curl;
     }
 
     if ($is_download) {
       if ($this->downloadHandle) {
         fflush($this->downloadHandle);
         fclose($this->downloadHandle);
         $this->downloadHandle = null;
       }
     }
 
     $sink = $this->getProgressSink();
     if ($sink) {
       $status = head($this->getResult());
       if ($status->isError()) {
         $sink->didFailWork();
       } else {
         $sink->didCompleteWork();
       }
     }
 
     return true;
   }
 
 
   /**
    * Callback invoked by cURL as it reads HTTP data from the response. We save
    * the data to a buffer.
    */
   public function didReceiveDataCallback($handle, $data) {
     if ($this->parser) {
       $this->parser->readBytes($data);
     } else {
       $this->responseBuffer .= $data;
     }
 
     return strlen($data);
   }
 
 
   /**
    * Read data from the response buffer.
    *
    * NOTE: Like @{class:ExecFuture}, this method advances a read cursor but
    * does not discard the data. The data will still be buffered, and it will
    * all be returned when the future resolves. To discard the data after
    * reading it, call @{method:discardBuffers}.
    *
    * @return string Response data, if available.
    */
   public function read() {
     if ($this->isDownload()) {
       throw new Exception(
         pht(
           'You can not read the result buffer while streaming results '.
           'to disk: there is no in-memory buffer to read.'));
     }
 
     if ($this->parser) {
       throw new Exception(
         pht(
           'Streaming reads are not currently supported by the streaming '.
           'parser.'));
     }
 
     $result = substr($this->responseBuffer, $this->responseBufferPos);
     $this->responseBufferPos = strlen($this->responseBuffer);
     return $result;
   }
 
 
   /**
    * Discard any buffered data. Normally, you call this after reading the
    * data with @{method:read}.
    *
    * @return this
    */
   public function discardBuffers() {
     if ($this->isDownload()) {
       throw new Exception(
         pht(
           'You can not discard the result buffer while streaming results '.
           'to disk: there is no in-memory buffer to discard.'));
     }
 
     if ($this->parser) {
       throw new Exception(
         pht(
           'Buffer discards are not currently supported by the streaming '.
           'parser.'));
     }
 
     $this->responseBuffer = '';
     $this->responseBufferPos = 0;
     return $this;
   }
 
 
   /**
    * Produces a value safe to pass to `CURLOPT_POSTFIELDS`.
    *
    * @return wild   Some value, suitable for use in `CURLOPT_POSTFIELDS`.
    */
   private function formatRequestDataForCURL() {
     // We're generating a value to hand to cURL as CURLOPT_POSTFIELDS. The way
     // cURL handles this value has some tricky caveats.
 
     // First, we can return either an array or a query string. If we return
     // an array, we get a "multipart/form-data" request. If we return a
     // query string, we get an "application/x-www-form-urlencoded" request.
 
     // Second, if we return an array we can't duplicate keys. The user might
     // want to send the same parameter multiple times.
 
     // Third, if we return an array and any of the values start with "@",
     // cURL includes arbitrary files off disk and sends them to an untrusted
     // remote server. For example, an array like:
     //
     //   array('name' => '@/usr/local/secret')
     //
     // ...will attempt to read that file off disk and transmit its contents with
     // the request. This behavior is pretty surprising, and it can easily
     // become a relatively severe security vulnerability which allows an
     // attacker to read any file the HTTP process has access to. Since this
     // feature is very dangerous and not particularly useful, we prevent its
     // use. Broadly, this means we must reject some requests because they
     // contain an "@" in an inconvenient place.
 
     // Generally, to avoid the "@" case and because most servers usually
     // expect "application/x-www-form-urlencoded" data, we try to return a
     // string unless there are files attached to this request.
 
     $data = $this->getData();
     $files = $this->files;
 
     $any_data = ($data || (is_string($data) && strlen($data)));
     $any_files = (bool)$this->files;
 
     if (!$any_data && !$any_files) {
       // No files or data, so just bail.
       return null;
     }
 
     if (!$any_files) {
       // If we don't have any files, just encode the data as a query string,
       // make sure it's not including any files, and we're good to go.
       if (is_array($data)) {
         $data = phutil_build_http_querystring($data);
       }
 
       $this->checkForDangerousCURLMagic($data, $is_query_string = true);
 
       return $data;
     }
 
     // If we've made it this far, we have some files, so we need to return
     // an array. First, convert the other data into an array if it isn't one
     // already.
 
     if (is_string($data)) {
       // NOTE: We explicitly don't want fancy array parsing here, so just
       // do a basic parse and then convert it into a dictionary ourselves.
       $parser = new PhutilQueryStringParser();
       $pairs = $parser->parseQueryStringToPairList($data);
 
       $map = array();
       foreach ($pairs as $pair) {
         list($key, $value) = $pair;
         if (array_key_exists($key, $map)) {
           throw new Exception(
             pht(
               'Request specifies two values for key "%s", but parameter '.
               'names must be unique if you are posting file data due to '.
               'limitations with cURL.',
               $key));
         }
         $map[$key] = $value;
       }
 
       $data = $map;
     }
 
     foreach ($data as $key => $value) {
       $this->checkForDangerousCURLMagic($value, $is_query_string = false);
     }
 
     foreach ($this->files as $name => $info) {
       if (array_key_exists($name, $data)) {
         throw new Exception(
           pht(
             'Request specifies a file with key "%s", but that key is also '.
             'defined by normal request data. Due to limitations with cURL, '.
             'requests that post file data must use unique keys.',
             $name));
       }
 
       $tmp = new TempFile($info['name']);
       Filesystem::writeFile($tmp, $info['data']);
       $this->temporaryFiles[] = $tmp;
 
       // In 5.5.0 and later, we can use CURLFile. Prior to that, we have to
       // use this "@" stuff.
 
       if (class_exists('CURLFile', false)) {
         $file_value = new CURLFile((string)$tmp, $info['mime'], $info['name']);
       } else {
         $file_value = '@'.(string)$tmp;
       }
 
       $data[$name] = $file_value;
     }
 
     return $data;
   }
 
 
   /**
    * Detect strings which will cause cURL to do horrible, insecure things.
    *
    * @param string  Possibly dangerous string.
    * @param bool    True if this string is being used as part of a query string.
    * @return void
    */
   private function checkForDangerousCURLMagic($string, $is_query_string) {
     if (empty($string[0]) || ($string[0] != '@')) {
       // This isn't an "@..." string, so it's fine.
       return;
     }
 
     if ($is_query_string) {
       if (version_compare(phpversion(), '5.2.0', '<')) {
         throw new Exception(
           pht(
             'Attempting to make an HTTP request, but query string data begins '.
             'with "%s". Prior to PHP 5.2.0 this reads files off disk, which '.
             'creates a wide attack window for security vulnerabilities. '.
             'Upgrade PHP or avoid making cURL requests which begin with "%s".',
             '@',
             '@'));
       }
 
       // This is safe if we're on PHP 5.2.0 or newer.
       return;
     }
 
     throw new Exception(
       pht(
         'Attempting to make an HTTP request which includes file data, but the '.
         'value of a query parameter begins with "%s". PHP interprets these '.
         'values to mean that it should read arbitrary files off disk and '.
         'transmit them to remote servers. Declining to make this request.',
         '@'));
   }
 
 
   /**
    * Determine whether CURLOPT_CAINFO is usable on this system.
    */
   private function canSetCAInfo() {
     // We cannot set CAInfo on OSX after Yosemite.
 
     $osx_version = PhutilExecutionEnvironment::getOSXVersion();
     if ($osx_version) {
       if (version_compare($osx_version, 14, '>=')) {
         return false;
       }
     }
 
     return true;
   }
 
 
   /**
    * Write a raw HTTP body into the request.
    *
    * You must write the entire body before starting the request.
    *
    * @param string Raw body.
    * @return this
    */
   public function write($raw_body) {
     $this->rawBody = $raw_body;
     return $this;
   }
 
 
   /**
    * Callback to pass data to cURL.
    */
   public function willWriteBody($handle, $infile, $len) {
     $bytes = substr($this->rawBody, $this->rawBodyPos, $len);
     $this->rawBodyPos += $len;
     return $bytes;
   }
 
   private function shouldReuseHandles() {
     $curl_version = curl_version();
     $version = idx($curl_version, 'version');
 
     // NOTE: cURL 7.43.0 has a bug where the POST body length is not recomputed
     // properly when a handle is reused. For this version of cURL, disable
     // handle reuse and accept a small performance penalty. See T8654.
     if ($version == '7.43.0') {
       return false;
     }
 
     return true;
   }
 
   private function isDownload() {
    return ($this->downloadPath !== null);
   }
 
   protected function getServiceProfilerStartParameters() {
     return array(
       'type' => 'http',
       'uri' => phutil_string_cast($this->getURI()),
     );
   }
 
   private function canAcceptGzip() {
     return function_exists('gzdecode');
   }
 
 }
diff --git a/src/lint/linter/xhpast/rules/ArcanistCommentStyleXHPASTLinterRule.php b/src/lint/linter/xhpast/rules/ArcanistCommentStyleXHPASTLinterRule.php
index 3a493e2b..d95513ce 100644
--- a/src/lint/linter/xhpast/rules/ArcanistCommentStyleXHPASTLinterRule.php
+++ b/src/lint/linter/xhpast/rules/ArcanistCommentStyleXHPASTLinterRule.php
@@ -1,31 +1,39 @@
 <?php
 
 final class ArcanistCommentStyleXHPASTLinterRule
   extends ArcanistXHPASTLinterRule {
 
   const ID = 18;
 
   public function getLintName() {
     return pht('Comment Style');
   }
 
   public function process(XHPASTNode $root) {
     foreach ($root->selectTokensOfType('T_COMMENT') as $comment) {
       $value = $comment->getValue();
 
       if ($value[0] !== '#') {
         continue;
       }
 
+      // Don't warn about PHP comment directives. In particular, we need
+      // to use "#[\ReturnTypeWillChange]" to implement "Iterator" in a way
+      // that is compatible with PHP 8.1 and older versions of PHP prior
+      // to the introduction of return types. See T13588.
+      if (preg_match('/^#\\[\\\\/', $value)) {
+        continue;
+      }
+
       $this->raiseLintAtOffset(
         $comment->getOffset(),
         pht(
           'Use `%s` single-line comments, not `%s`.',
           '//',
           '#'),
         '#',
         preg_match('/^#\S/', $value) ? '// ' : '//');
     }
   }
 
 }
diff --git a/src/lint/linter/xhpast/rules/__tests__/comment-style/hash-directives.lint-test b/src/lint/linter/xhpast/rules/__tests__/comment-style/hash-directives.lint-test
new file mode 100644
index 00000000..8ea0ed16
--- /dev/null
+++ b/src/lint/linter/xhpast/rules/__tests__/comment-style/hash-directives.lint-test
@@ -0,0 +1,23 @@
+<?php
+
+class X implements Iterator {
+
+  #[\ReturnTypeWillChange]
+  public function reset() {
+    # See T13588 for PHP8.1 compatibility information.
+  }
+
+}
+~~~~~~~~~~
+error:7:5:XHP18:Comment Style
+~~~~~~~~~~
+<?php
+
+class X implements Iterator {
+
+  #[\ReturnTypeWillChange]
+  public function reset() {
+    // See T13588 for PHP8.1 compatibility information.
+  }
+
+}
diff --git a/src/object/Phobject.php b/src/object/Phobject.php
index 0a7a59ee..4d799306 100644
--- a/src/object/Phobject.php
+++ b/src/object/Phobject.php
@@ -1,104 +1,109 @@
 <?php
 
 /**
  * Base class for libphutil objects. Enforces stricter object semantics than
  * PHP.
  *
  * When a program attempts to write to an undeclared object property, PHP
  * creates the property. However, in libphutil this is always an error (for
  * example, a misspelled property name).  Instead of permitting the write,
  * subclasses will throw when an undeclared property is written.
  *
  * When a program attempts to iterate an object (for example, with `foreach`),
  * PHP iterates its public members. However, in libphutil this is always an
  * error (for example, iterating over the wrong variable). Instead of
  * permitting the iteration, subclasses will throw when an object is iterated.
  *
  * (Legitimately iterable subclasses can provide a working implementation of
  * Iterator instead.)
  */
 abstract class Phobject implements Iterator {
 
   public function __get($name) {
     throw new DomainException(
       pht(
         'Attempt to read from undeclared property %s.',
         get_class($this).'::'.$name));
     }
 
   public function __set($name, $value) {
     throw new DomainException(
       pht(
         'Attempt to write to undeclared property %s.',
         get_class($this).'::'.$name));
   }
 
+  #[\ReturnTypeWillChange]
   public function current() {
     $this->throwOnAttemptedIteration();
   }
 
+  #[\ReturnTypeWillChange]
   public function key() {
     $this->throwOnAttemptedIteration();
   }
 
+  #[\ReturnTypeWillChange]
   public function next() {
     $this->throwOnAttemptedIteration();
   }
 
+  #[\ReturnTypeWillChange]
   public function rewind() {
     $this->throwOnAttemptedIteration();
   }
 
+  #[\ReturnTypeWillChange]
   public function valid() {
     $this->throwOnAttemptedIteration();
   }
 
   private function throwOnAttemptedIteration() {
     throw new DomainException(
       pht(
         'Attempting to iterate an object (of class %s) which is not iterable.',
         get_class($this)));
   }
 
 
   /**
    * Read the value of a class constant.
    *
    * This is the same as just typing `self::CONSTANTNAME`, but throws a more
    * useful message if the constant is not defined and allows the constant to
    * be limited to a maximum length.
    *
    * @param string Name of the constant.
    * @param int|null Maximum number of bytes permitted in the value.
    * @return string Value of the constant.
    */
   public function getPhobjectClassConstant($key, $byte_limit = null) {
     $class = new ReflectionClass($this);
 
     $const = $class->getConstant($key);
     if ($const === false) {
       throw new Exception(
         pht(
           '"%s" class "%s" must define a "%s" constant.',
           __CLASS__,
           get_class($this),
           $key));
     }
 
     if ($byte_limit !== null) {
       if (!is_string($const) || (strlen($const) > $byte_limit)) {
         throw new Exception(
           pht(
             '"%s" class "%s" has an invalid "%s" property. Field constants '.
             'must be strings and no more than %s bytes in length.',
             __CLASS__,
             get_class($this),
             $key,
             new PhutilNumber($byte_limit)));
       }
     }
 
     return $const;
   }
 
 }
diff --git a/src/parser/PhutilURI.php b/src/parser/PhutilURI.php
index 7ddd3074..1902cd1f 100644
--- a/src/parser/PhutilURI.php
+++ b/src/parser/PhutilURI.php
@@ -1,559 +1,559 @@
 <?php
 
 /**
  * Structural representation of a URI.
  *
  * This class handles URIs of two types: standard URIs and Git URIs.
  *
  * Standard URIs look like `proto://user:pass@domain:port/path?query#fragment`.
  * Almost all URIs are in this form.
  *
  * Git URIs look like `user@host:path`. These URIs are used by Git and SCP
  * and have an implicit "ssh" protocol, no port, and interpret paths as
  * relative instead of absolute.
  */
 final class PhutilURI extends Phobject {
 
   private $protocol;
   private $user;
   private $pass;
   private $domain;
   private $port;
   private $path;
   private $query = array();
   private $fragment;
   private $type;
 
   const TYPE_URI = 'uri';
   const TYPE_GIT = 'git';
 
   public function __construct($uri, $params = array()) {
     if ($uri instanceof PhutilURI) {
       $this->protocol = $uri->protocol;
       $this->user = $uri->user;
       $this->pass = $uri->pass;
       $this->domain = $uri->domain;
       $this->port = $uri->port;
       $this->path = $uri->path;
       $this->query = $uri->query;
       $this->fragment = $uri->fragment;
       $this->type = $uri->type;
 
       $this->initializeQueryParams(phutil_string_cast($uri), $params);
 
       return;
     }
 
     $uri = phutil_string_cast($uri);
 
     $type = self::TYPE_URI;
 
     // Reject ambiguous URIs outright. Different versions of different clients
     // parse these in different ways. See T12526 for discussion.
     if (preg_match('(^[^/:]*://[^/]*[#?].*:)', $uri)) {
       throw new Exception(
         pht(
           'Rejecting ambiguous URI "%s". This URI is not formatted or '.
           'encoded properly.',
           $uri));
     }
 
     $matches = null;
     if (preg_match('(^([^/:]*://[^/]*)(\\?.*)\z)', $uri, $matches)) {
       // If the URI is something like `idea://open?file=/path/to/file`, the
       // `parse_url()` function will parse `open?file=` as the host. This is
       // not the expected result. Break the URI into two pieces, stick a slash
       // in between them, parse that, then remove the path. See T6106.
 
       $parts = parse_url($matches[1].'/'.$matches[2]);
       unset($parts['path']);
     } else if ($this->isGitURIPattern($uri)) {
       // Handle Git/SCP URIs in the form "user@domain:relative/path".
 
       $user = '(?:(?P<user>[^/@]+)@)?';
       $host = '(?P<host>[^/:]+)';
       $path = ':(?P<path>.*)';
 
       $ok = preg_match('(^'.$user.$host.$path.'\z)', $uri, $matches);
       if (!$ok) {
         throw new Exception(
           pht(
             'Failed to parse URI "%s" as a Git URI.',
             $uri));
       }
 
       $parts = $matches;
       $parts['scheme'] = 'ssh';
 
       $type = self::TYPE_GIT;
     } else {
       $parts = parse_url($uri);
     }
 
     // The parse_url() call will accept URIs with leading whitespace, but many
     // other tools (like git) will not. See T4913 for a specific example. If
     // the input string has leading whitespace, fail the parse.
     if ($parts) {
       if (ltrim($uri) != $uri) {
         $parts = false;
       }
     }
 
     // NOTE: `parse_url()` is very liberal about host names; fail the parse if
     // the host looks like garbage. In particular, we do not allow hosts which
     // begin with "." or "-". See T12961 for a specific attack which relied on
     // hosts beginning with "-".
     if ($parts) {
       $host = idx($parts, 'host', '');
       if (strlen($host)) {
         if (!preg_match('/^[a-zA-Z0-9]+[a-zA-Z0-9\\.\\-]*\z/', $host)) {
           $parts = false;
         }
       }
     }
 
     if (!$parts) {
       $parts = array();
     }
 
     // stringyness is to preserve API compatibility and
     // allow the tests to continue passing
     $this->protocol = idx($parts, 'scheme', '');
     $this->user = rawurldecode(idx($parts, 'user', ''));
     $this->pass = rawurldecode(idx($parts, 'pass', ''));
     $this->domain = idx($parts, 'host', '');
     $this->port = (string)idx($parts, 'port', '');
     $this->path = idx($parts, 'path', '');
     $query = idx($parts, 'query');
     if ($query) {
       $pairs = id(new PhutilQueryStringParser())
         ->parseQueryStringToPairList($query);
 
       foreach ($pairs as $pair) {
         list($key, $value) = $pair;
         $this->appendQueryParam($key, $value);
       }
     }
     $this->fragment = idx($parts, 'fragment', '');
 
     $this->type = $type;
 
     $this->initializeQueryParams($uri, $params);
   }
 
   public function __toString() {
     $prefix = null;
 
     if ($this->isGitURI()) {
       $port = null;
     } else {
       $port = $this->port;
     }
 
     $domain = $this->domain;
 
     $user = $this->user;
     $pass = $this->pass;
     if (strlen($user) && strlen($pass)) {
       $auth = rawurlencode($user).':'.rawurlencode($pass).'@';
     } else if (strlen($user)) {
       $auth = rawurlencode($user).'@';
     } else {
       $auth = null;
     }
 
     $protocol = $this->protocol;
     if ($this->isGitURI()) {
       $protocol = null;
     } else {
-      if (strlen($auth)) {
+      if ($auth !== null) {
         $protocol = nonempty($this->protocol, 'http');
       }
     }
 
     if (strlen($protocol) || strlen($auth) || strlen($domain)) {
       if ($this->isGitURI()) {
         $prefix = "{$auth}{$domain}";
       } else {
         $prefix = "{$protocol}://{$auth}{$domain}";
       }
 
       if (strlen($port)) {
         $prefix .= ':'.$port;
       }
     }
 
     if ($this->query) {
       $query = '?'.phutil_build_http_querystring_from_pairs($this->query);
     } else {
       $query = null;
     }
 
     if (strlen($this->getFragment())) {
       $fragment = '#'.$this->getFragment();
     } else {
       $fragment = null;
     }
 
     $path = $this->getPath();
     if ($this->isGitURI()) {
       if (strlen($path)) {
         $path = ':'.$path;
       }
     }
 
     return $prefix.$path.$query.$fragment;
   }
 
   /**
    * @deprecated
    */
   public function setQueryParam($key, $value) {
     // To set, we replace the first matching key with the new value, then
     // remove all other matching keys. This replaces the old value and retains
     // the parameter order.
 
     $is_null = ($value === null);
 
     // Typecheck and cast the key before we compare it to existing keys. This
     // raises an early exception if the key has a bad type.
     list($key) = phutil_http_parameter_pair($key, '');
 
     $found = false;
     foreach ($this->query as $list_key => $pair) {
       list($k, $v) = $pair;
 
       if ($k !== $key) {
         continue;
       }
 
       if ($found) {
         unset($this->query[$list_key]);
         continue;
       }
 
       $found = true;
 
       if ($is_null) {
         unset($this->query[$list_key]);
       } else {
         $this->insertQueryParam($key, $value, $list_key);
       }
     }
 
     $this->query = array_values($this->query);
 
     // If we didn't find an existing place to put it, add it to the end.
     if (!$found) {
       if (!$is_null) {
         $this->appendQueryParam($key, $value);
       }
     }
 
     return $this;
   }
 
   /**
    * @deprecated
    */
   public function setQueryParams(array $params) {
     $this->query = array();
 
     foreach ($params as $k => $v) {
       $this->appendQueryParam($k, $v);
     }
 
     return $this;
   }
 
   /**
    * @deprecated
    */
   public function getQueryParams() {
     $map = array();
 
     foreach ($this->query as $pair) {
       list($k, $v) = $pair;
       $map[$k] = $v;
     }
 
     return $map;
   }
 
   public function getQueryParamsAsMap() {
     $map = array();
 
     foreach ($this->query as $pair) {
       list($k, $v) = $pair;
 
       if (isset($map[$k])) {
         throw new Exception(
           pht(
             'Query parameters include a duplicate key ("%s") and can not be '.
             'nondestructively represented as a map.',
             $k));
       }
 
       $map[$k] = $v;
     }
 
     return $map;
   }
 
   public function getQueryParamsAsPairList() {
     return $this->query;
   }
 
   public function appendQueryParam($key, $value) {
     return $this->insertQueryParam($key, $value);
   }
 
   public function removeAllQueryParams() {
     $this->query = array();
     return $this;
   }
 
   public function removeQueryParam($remove_key) {
     list($remove_key) = phutil_http_parameter_pair($remove_key, '');
 
     foreach ($this->query as $idx => $pair) {
       list($key, $value) = $pair;
 
       if ($key !== $remove_key) {
         continue;
       }
 
       unset($this->query[$idx]);
     }
 
     $this->query = array_values($this->query);
 
     return $this;
   }
 
   public function replaceQueryParam($replace_key, $replace_value) {
     if ($replace_value === null) {
       throw new InvalidArgumentException(
         pht(
           'Value provided to "replaceQueryParam()" for key "%s" is NULL. '.
           'Use "removeQueryParam()" to remove a query parameter.',
           $replace_key));
     }
 
     $this->removeQueryParam($replace_key);
     $this->appendQueryParam($replace_key, $replace_value);
     return $this;
   }
 
   private function insertQueryParam($key, $value, $idx = null) {
     list($key, $value) = phutil_http_parameter_pair($key, $value);
 
     if ($idx === null) {
       $this->query[] = array($key, $value);
     } else {
       $this->query[$idx] = array($key, $value);
     }
 
     return $this;
   }
 
   private function initializeQueryParams($uri, array $params) {
     $have_params = array();
     foreach ($this->query as $pair) {
       list($key) = $pair;
       $have_params[$key] = true;
     }
 
     foreach ($params as $key => $value) {
       if (isset($have_params[$key])) {
         throw new InvalidArgumentException(
           pht(
             'You are trying to construct an ambiguous URI: query parameter '.
             '"%s" is present in both the string argument ("%s") and the map '.
             'argument.',
             $key,
             $uri));
       }
 
       if ($value === null) {
         continue;
       }
 
       $this->appendQueryParam($key, $value);
     }
 
     return $this;
   }
 
   public function setProtocol($protocol) {
     $this->protocol = $protocol;
     return $this;
   }
 
   public function getProtocol() {
     return $this->protocol;
   }
 
   public function setDomain($domain) {
     $this->domain = $domain;
     return $this;
   }
 
   public function getDomain() {
     return $this->domain;
   }
 
   public function setPort($port) {
     $this->port = $port;
     return $this;
   }
   public function getPort() {
     return $this->port;
   }
 
   public function getPortWithProtocolDefault() {
     static $default_ports = array(
       'http'  => '80',
       'https' => '443',
       'ssh'   => '22',
     );
 
     return nonempty(
       $this->getPort(),
       idx($default_ports, $this->getProtocol()),
       '');
   }
 
   public function setPath($path) {
     if ($this->isGitURI()) {
       // Git URIs use relative paths which do not need to begin with "/".
     } else {
       if ($this->domain && strlen($path) && $path[0] !== '/') {
         $path = '/'.$path;
       }
     }
 
     $this->path = $path;
     return $this;
   }
 
   public function appendPath($path) {
     $first = strlen($path) ? $path[0] : null;
     $last  = strlen($this->path) ? $this->path[strlen($this->path) - 1] : null;
 
     if (!$this->path) {
       return $this->setPath($path);
     } else if ($first === '/' && $last === '/') {
       $path = substr($path, 1);
     } else if ($first !== '/' && $last !== '/') {
       $path = '/'.$path;
     }
 
     $this->path .= $path;
     return $this;
   }
 
   public function getPath() {
     return $this->path;
   }
 
   public function setFragment($fragment) {
     $this->fragment = $fragment;
     return $this;
   }
 
   public function getFragment() {
     return $this->fragment;
   }
 
   public function setUser($user) {
     $this->user = $user;
     return $this;
   }
 
   public function getUser() {
     return $this->user;
   }
 
   public function setPass($pass) {
     $this->pass = $pass;
     return $this;
   }
 
   public function getPass() {
     return $this->pass;
   }
 
   public function alter($key, $value) {
     $altered = clone $this;
     $altered->replaceQueryParam($key, $value);
     return $altered;
   }
 
   public function isGitURI() {
     return ($this->type == self::TYPE_GIT);
   }
 
   public function setType($type) {
 
     if ($type == self::TYPE_URI) {
       $path = $this->getPath();
       if (strlen($path) && ($path[0] !== '/')) {
         // Try to catch this here because we are not allowed to throw from
         // inside __toString() so we don't have a reasonable opportunity to
         // react properly if we catch it later.
         throw new Exception(
           pht(
             'Unable to convert URI "%s" into a standard URI because the '.
             'path is relative. Standard URIs can not represent relative '.
             'paths.',
             $this));
       }
     }
 
     $this->type = $type;
     return $this;
   }
 
   public function getType() {
     return $this->type;
   }
 
   private function isGitURIPattern($uri) {
     $matches = null;
 
     $ok = preg_match('(^(?P<head>[^/]+):(?P<last>(?!//).*)\z)', $uri, $matches);
     if (!$ok) {
       return false;
     }
 
     $head = $matches['head'];
     $last = $matches['last'];
 
     // If any part of this has spaces in it, it's not a Git URI. We fail here
     // so we fall back and don't fail more abruptly later.
     if (preg_match('(\s)', $head.$last)) {
       return false;
     }
 
     // If the second part only contains digits, assume we're looking at
     // casually specified "domain.com:123" URI, not a Git URI pointed at an
     // entirely numeric relative path.
     if (preg_match('(^\d+\z)', $last)) {
       return false;
     }
 
     // If the first part has a "." or an "@" in it, interpret it as a domain
     // or a "user@host" string.
     if (preg_match('([.@])', $head)) {
       return true;
     }
 
     // Otherwise, interpret the URI conservatively as a "javascript:"-style
     // URI. This means that "localhost:path" is parsed as a normal URI instead
     // of a Git URI, but we can't tell which the user intends and it's safer
     // to treat it as a normal URI.
     return false;
   }
 
 }
diff --git a/src/parser/argument/PhutilArgumentParser.php b/src/parser/argument/PhutilArgumentParser.php
index 6fe5bc66..1bf31a23 100644
--- a/src/parser/argument/PhutilArgumentParser.php
+++ b/src/parser/argument/PhutilArgumentParser.php
@@ -1,1012 +1,1017 @@
 <?php
 
 /**
  * Parser for command-line arguments for scripts. Like similar parsers, this
  * class allows you to specify, validate, and render help for command-line
  * arguments. For example:
  *
  *   name=create_dog.php
  *   $args = new PhutilArgumentParser($argv);
  *   $args->setTagline('make an new dog')
  *   $args->setSynopsis(<<<EOHELP
  *   **dog** [--big] [--name __name__]
  *   Create a new dog. How does it work? Who knows.
  *   EOHELP
  *   );
  *   $args->parse(
  *     array(
  *       array(
  *         'name'     => 'name',
  *         'param'    => 'dogname',
  *         'default'  => 'Rover',
  *         'help'     => 'Set the dog\'s name. By default, the dog will be '.
  *                       'named "Rover".',
  *       ),
  *       array(
  *         'name'     => 'big',
  *         'short'    => 'b',
  *         'help'     => 'If set, create a large dog.',
  *       ),
  *     ));
  *
  *   $dog_name = $args->getArg('name');
  *   $dog_size = $args->getArg('big') ? 'big' : 'small';
  *
  *   // ... etc ...
  *
  * (For detailed documentation on supported keys in argument specifications,
  * see @{class:PhutilArgumentSpecification}.)
  *
  * This will handle argument parsing, and generate appropriate usage help if
  * the user provides an unsupported flag. @{class:PhutilArgumentParser} also
  * supports some builtin "standard" arguments:
  *
  *   $args->parseStandardArguments();
  *
  * See @{method:parseStandardArguments} for details. Notably, this includes
  * a "--help" flag, and an "--xprofile" flag for profiling command-line scripts.
  *
  * Normally, when the parser encounters an unknown flag, it will exit with
  * an error. However, you can use @{method:parsePartial} to consume only a
  * set of flags:
  *
  *   $args->parsePartial($spec_list);
  *
  * This allows you to parse some flags before making decisions about other
  * parsing, or share some flags across scripts. The builtin standard arguments
  * are implemented in this way.
  *
  * There is also builtin support for "workflows", which allow you to build a
  * script that operates in several modes (e.g., by accepting commands like
  * `install`, `upgrade`, etc), like `arc` does. For detailed documentation on
  * workflows, see @{class:PhutilArgumentWorkflow}.
  *
  * @task parse    Parsing Arguments
  * @task read     Reading Arguments
  * @task help     Command Help
  * @task internal Internals
  */
 final class PhutilArgumentParser extends Phobject {
 
   private $bin;
   private $argv;
   private $specs = array();
   private $results = array();
   private $parsed;
 
   private $tagline;
   private $synopsis;
   private $workflows;
   private $helpWorkflows;
   private $showHelp;
   private $requireArgumentTerminator = false;
   private $sawTerminator = false;
 
   const PARSE_ERROR_CODE = 77;
 
   private static $traceModeEnabled = false;
 
 
 /* -(  Parsing Arguments  )-------------------------------------------------- */
 
 
   /**
    * Build a new parser. Generally, you start a script with:
    *
    *   $args = new PhutilArgumentParser($argv);
    *
    * @param list  Argument vector to parse, generally the $argv global.
    * @task parse
    */
   public function __construct(array $argv) {
     $this->bin = $argv[0];
     $this->argv = array_slice($argv, 1);
   }
 
 
   /**
    * Parse and consume a list of arguments, removing them from the argument
    * vector but leaving unparsed arguments for later consumption. You can
    * retrieve unconsumed arguments directly with
    * @{method:getUnconsumedArgumentVector}. Doing a partial parse can make it
    * easier to share common flags across scripts or workflows.
    *
    * @param   list  List of argument specs, see
    *                @{class:PhutilArgumentSpecification}.
    * @param bool Require flags appear before any non-flag arguments.
    * @return  this
    * @task parse
    */
   public function parsePartial(array $specs, $initial_only = false) {
     return $this->parseInternal($specs, false, $initial_only);
   }
 
   /**
    * @return  this
    */
   private function parseInternal(
     array $specs,
     $correct_spelling,
     $initial_only) {
 
     $specs = PhutilArgumentSpecification::newSpecsFromList($specs);
     $this->mergeSpecs($specs);
 
     // Wildcard arguments have a name like "argv", but we don't want to
     // parse a corresponding flag like "--argv". Filter them out before
     // building a list of available flags.
     $non_wildcard = array();
     foreach ($specs as $spec_key => $spec) {
       if ($spec->getWildcard()) {
         continue;
       }
 
       $non_wildcard[$spec_key] = $spec;
     }
 
     $specs_by_name  = mpull($non_wildcard, null, 'getName');
     $specs_by_short = mpull($non_wildcard, null, 'getShortAlias');
     unset($specs_by_short[null]);
 
     $argv = $this->argv;
     $len = count($argv);
     $is_initial = true;
     for ($ii = 0; $ii < $len; $ii++) {
       $arg = $argv[$ii];
       $map = null;
       $options = null;
       if (!is_string($arg)) {
         // Non-string argument; pass it through as-is.
       } else if ($arg == '--') {
         // This indicates "end of flags".
         $this->sawTerminator = true;
         break;
       } else if ($arg == '-') {
         // This is a normal argument (e.g., stdin).
         continue;
       } else if (!strncmp('--', $arg, 2)) {
         $pre = '--';
         $arg = substr($arg, 2);
         $map = $specs_by_name;
         $options = array_keys($specs_by_name);
       } else if (!strncmp('-', $arg, 1) && strlen($arg) > 1) {
         $pre = '-';
         $arg = substr($arg, 1);
         $map = $specs_by_short;
       } else {
         $is_initial = false;
       }
 
       if ($map) {
         $val = null;
         $parts = explode('=', $arg, 2);
         if (count($parts) == 2) {
           list($arg, $val) = $parts;
         }
 
         // Try to correct flag spelling for full flags, to allow users to make
         // minor mistakes.
         if ($correct_spelling && $options && !isset($map[$arg])) {
           $corrections = PhutilArgumentSpellingCorrector::newFlagCorrector()
             ->correctSpelling($arg, $options);
 
           $should_autocorrect = $this->shouldAutocorrect();
           if (count($corrections) == 1 && $should_autocorrect) {
             $corrected = head($corrections);
 
             $this->logMessage(
               tsprintf(
                 "%s\n",
                 pht(
                   '(Assuming "%s" is the British spelling of "%s".)',
                   $pre.$arg,
                   $pre.$corrected)));
 
             $arg = $corrected;
           }
         }
 
         if (isset($map[$arg])) {
           if ($initial_only && !$is_initial) {
             throw new PhutilArgumentUsageException(
               pht(
                 'Argument "%s" appears after the first non-flag argument. '.
                 'This special argument must appear before other arguments.',
                 "{$pre}{$arg}"));
           }
 
           $spec = $map[$arg];
           unset($argv[$ii]);
 
           $param_name = $spec->getParamName();
           if ($val !== null) {
             if ($param_name === null) {
               throw new PhutilArgumentUsageException(
                 pht(
                   'Argument "%s" does not take a parameter.',
                   "{$pre}{$arg}"));
             }
           } else {
             if ($param_name !== null) {
               if ($ii + 1 < $len) {
                 $val = $argv[$ii + 1];
                 unset($argv[$ii + 1]);
                 $ii++;
               } else {
                 throw new PhutilArgumentUsageException(
                   pht(
                     'Argument "%s" requires a parameter.',
                     "{$pre}{$arg}"));
               }
             } else {
               $val = true;
             }
           }
 
           if (!$spec->getRepeatable()) {
             if (array_key_exists($spec->getName(), $this->results)) {
               throw new PhutilArgumentUsageException(
                 pht(
                   'Argument "%s" was provided twice.',
                   "{$pre}{$arg}"));
             }
           }
 
           $conflicts = $spec->getConflicts();
           foreach ($conflicts as $conflict => $reason) {
             if (array_key_exists($conflict, $this->results)) {
 
               if (!is_string($reason) || !strlen($reason)) {
                 $reason = '.';
               } else {
                 $reason = ': '.$reason.'.';
               }
 
               throw new PhutilArgumentUsageException(
                 pht(
                   'Argument "%s" conflicts with argument "%s"%s',
                   "{$pre}{$arg}",
                   "--{$conflict}",
                   $reason));
             }
           }
 
           if ($spec->getRepeatable()) {
             if ($spec->getParamName() === null) {
               if (empty($this->results[$spec->getName()])) {
                 $this->results[$spec->getName()] = 0;
               }
               $this->results[$spec->getName()]++;
             } else {
               $this->results[$spec->getName()][] = $val;
             }
           } else {
             $this->results[$spec->getName()] = $val;
           }
         }
       }
     }
 
     foreach ($specs as $spec) {
       if ($spec->getWildcard()) {
         $this->results[$spec->getName()] = $this->filterWildcardArgv($argv);
         $argv = array();
         break;
       }
     }
 
     $this->argv = array_values($argv);
 
     return $this;
   }
 
 
   /**
    * Parse and consume a list of arguments, throwing an exception if there is
    * anything left unconsumed. This is like @{method:parsePartial}, but raises
    * a {class:PhutilArgumentUsageException} if there are leftovers.
    *
    * Normally, you would call @{method:parse} instead, which emits a
    * user-friendly error. You can also use @{method:printUsageException} to
    * render the exception in a user-friendly way.
    *
    * @param   list  List of argument specs, see
    *                @{class:PhutilArgumentSpecification}.
    * @return  this
    * @task parse
    */
   public function parseFull(array $specs) {
     $this->parseInternal($specs, true, false);
 
     // If we have remaining unconsumed arguments other than a single "--",
     // fail.
     $argv = $this->filterWildcardArgv($this->argv);
     if ($argv) {
       throw new PhutilArgumentUsageException(
         pht(
           'Unrecognized argument "%s".',
           head($argv)));
     }
 
     if ($this->getRequireArgumentTerminator()) {
       if (!$this->sawTerminator) {
         throw new ArcanistMissingArgumentTerminatorException();
       }
     }
 
     if ($this->showHelp) {
       $this->printHelpAndExit();
     }
 
     return $this;
   }
 
 
   /**
    * Parse and consume a list of arguments, raising a user-friendly error if
    * anything remains. See also @{method:parseFull} and @{method:parsePartial}.
    *
    * @param   list  List of argument specs, see
    *                @{class:PhutilArgumentSpecification}.
    * @return  this
    * @task parse
    */
   public function parse(array $specs) {
     try {
       return $this->parseFull($specs);
     } catch (PhutilArgumentUsageException $ex) {
       $this->printUsageException($ex);
       exit(self::PARSE_ERROR_CODE);
     }
   }
 
 
   /**
    * Parse and execute workflows, raising a user-friendly error if anything
    * remains. See also @{method:parseWorkflowsFull}.
    *
    * See @{class:PhutilArgumentWorkflow} for details on using workflows.
    *
    * @param   list  List of argument specs, see
    *                @{class:PhutilArgumentSpecification}.
    * @return  this
    * @task parse
    */
   public function parseWorkflows(array $workflows) {
     try {
       return $this->parseWorkflowsFull($workflows);
     } catch (PhutilArgumentUsageException $ex) {
       $this->printUsageException($ex);
       exit(self::PARSE_ERROR_CODE);
     }
   }
 
 
   /**
    * Select a workflow. For commands that may operate in several modes, like
    * `arc`, the modes can be split into "workflows". Each workflow specifies
    * the arguments it accepts. This method takes a list of workflows, selects
    * the chosen workflow, parses its arguments, and either executes it (if it
    * is executable) or returns it for handling.
    *
    * See @{class:PhutilArgumentWorkflow} for details on using workflows.
    *
    * @param list List of @{class:PhutilArgumentWorkflow}s.
    * @return PhutilArgumentWorkflow|no  Returns the chosen workflow if it is
    *                                    not executable, or executes it and
    *                                    exits with a return code if it is.
    * @task parse
    */
   public function parseWorkflowsFull(array $workflows) {
     assert_instances_of($workflows, 'PhutilArgumentWorkflow');
 
     // Clear out existing workflows. We need to do this to permit the
     // construction of sub-workflows.
     $this->workflows = array();
 
     foreach ($workflows as $workflow) {
       $name = $workflow->getName();
 
       if ($name === null) {
         throw new PhutilArgumentSpecificationException(
           pht('Workflow has no name!'));
       }
 
       if (isset($this->workflows[$name])) {
         throw new PhutilArgumentSpecificationException(
           pht("Two workflows with name '%s!", $name));
       }
 
       $this->workflows[$name] = $workflow;
     }
 
     $argv = $this->argv;
     if (empty($argv)) {
       // TODO: this is kind of hacky / magical.
       if (isset($this->workflows['help'])) {
         $argv = array('help');
       } else {
         throw new PhutilArgumentUsageException(pht('No workflow selected.'));
       }
     }
 
     $flow = array_shift($argv);
 
     if (empty($this->workflows[$flow])) {
       $corrected = PhutilArgumentSpellingCorrector::newCommandCorrector()
         ->correctSpelling($flow, array_keys($this->workflows));
 
       $should_autocorrect = $this->shouldAutocorrect();
       if (count($corrected) == 1 && $should_autocorrect) {
         $corrected = head($corrected);
 
         $this->logMessage(
           tsprintf(
             "%s\n",
             pht(
               '(Assuming "%s" is the British spelling of "%s".)',
               $flow,
               $corrected)));
 
         $flow = $corrected;
       } else {
         if (!$this->showHelp) {
           $this->raiseUnknownWorkflow($flow, $corrected);
         }
       }
     }
 
     $workflow = idx($this->workflows, $flow);
 
     if ($this->showHelp) {
       // Make "cmd flow --help" behave like "cmd help flow", not "cmd help".
       $help_flow = idx($this->workflows, 'help');
       if ($help_flow) {
         if ($help_flow !== $workflow) {
           $workflow = $help_flow;
           $argv = array($flow);
 
           // Prevent parse() from dumping us back out to standard help.
           $this->showHelp = false;
         }
       } else {
         $this->printHelpAndExit();
       }
     }
 
     if (!$workflow) {
       $this->raiseUnknownWorkflow($flow, $corrected);
     }
 
     $this->argv = array_values($argv);
 
     if ($workflow->shouldParsePartial()) {
       $this->parsePartial($workflow->getArguments());
     } else {
       $this->parse($workflow->getArguments());
     }
 
 
     if ($workflow->isExecutable()) {
       $workflow->setArgv($this);
       $err = $workflow->execute($this);
       exit($err);
     } else {
       return $workflow;
     }
   }
 
 
   /**
    * Parse "standard" arguments and apply their effects:
    *
    *    --trace             Enable service call tracing.
    *    --no-ansi           Disable ANSI color/style sequences.
    *    --xprofile <file>   Write out an XHProf profile.
    *    --help              Show help.
    *
    * @return this
    *
    * @phutil-external-symbol function xhprof_enable
    */
   public function parseStandardArguments() {
     try {
       $this->parsePartial(
         array(
           array(
             'name'  => 'trace',
             'help'  => pht('Trace command execution and show service calls.'),
             'standard' => true,
           ),
           array(
             'name'  => 'no-ansi',
             'help'  => pht(
               'Disable ANSI terminal codes, printing plain text with '.
               'no color or style.'),
             'conflicts' => array(
               'ansi' => null,
             ),
             'standard' => true,
           ),
           array(
             'name'  => 'ansi',
             'help'  => pht(
               "Use formatting even in environments which probably ".
               "don't support it."),
             'standard' => true,
           ),
           array(
             'name'  => 'xprofile',
             'param' => 'profile',
             'help'  => pht(
               'Profile script execution and write results to a file.'),
             'standard' => true,
           ),
           array(
             'name'  => 'help',
             'short' => 'h',
             'help'  => pht('Show this help.'),
             'standard' => true,
           ),
           array(
             'name'  => 'show-standard-options',
             'help'  => pht(
               'Show every option, including standard options like this one.'),
             'standard' => true,
           ),
           array(
             'name'  => 'recon',
             'help'  => pht('Start in remote console mode.'),
             'standard' => true,
           ),
         ));
     } catch (PhutilArgumentUsageException $ex) {
       $this->printUsageException($ex);
       exit(self::PARSE_ERROR_CODE);
     }
 
     if ($this->getArg('trace')) {
       PhutilServiceProfiler::installEchoListener();
       self::$traceModeEnabled = true;
     }
 
     if ($this->getArg('no-ansi')) {
       PhutilConsoleFormatter::disableANSI(true);
     }
 
     if ($this->getArg('ansi')) {
       PhutilConsoleFormatter::disableANSI(false);
     }
 
     if ($this->getArg('help')) {
       $this->showHelp = true;
     }
 
     $xprofile = $this->getArg('xprofile');
     if ($xprofile) {
       if (!function_exists('xhprof_enable')) {
         throw new Exception(
           pht('To use "--xprofile", you must install XHProf.'));
       }
 
       xhprof_enable(0);
       register_shutdown_function(array($this, 'shutdownProfiler'));
     }
 
     $recon = $this->getArg('recon');
     if ($recon) {
       $remote_console = PhutilConsole::newRemoteConsole();
       $remote_console->beginRedirectOut();
       PhutilConsole::setConsole($remote_console);
     } else if ($this->getArg('trace')) {
       $server = new PhutilConsoleServer();
       $server->setEnableLog(true);
       $console = PhutilConsole::newConsoleForServer($server);
       PhutilConsole::setConsole($console);
     }
 
     return $this;
   }
 
 
 /* -(  Reading Arguments  )-------------------------------------------------- */
 
 
   public function getArg($name) {
     if (empty($this->specs[$name])) {
       throw new PhutilArgumentSpecificationException(
         pht('No specification exists for argument "%s"!', $name));
     }
 
     if (idx($this->results, $name) !== null) {
       return $this->results[$name];
     }
 
     return $this->specs[$name]->getDefault();
   }
 
   public function getUnconsumedArgumentVector() {
     return $this->argv;
   }
 
   public function setUnconsumedArgumentVector(array $argv) {
     $this->argv = $argv;
     return $this;
   }
 
   public function setWorkflows($workflows) {
     $workflows = mpull($workflows, null, 'getName');
     $this->workflows = $workflows;
     return $this;
   }
 
   public function setHelpWorkflows(array $help_workflows) {
     $help_workflows = mpull($help_workflows, null, 'getName');
     $this->helpWorkflows = $help_workflows;
     return $this;
   }
 
   public function getWorkflows() {
     return $this->workflows;
   }
 
 
 /* -(  Command Help  )------------------------------------------------------- */
 
   public function setRequireArgumentTerminator($require) {
     $this->requireArgumentTerminator = $require;
     return $this;
   }
 
   public function getRequireArgumentTerminator() {
     return $this->requireArgumentTerminator;
   }
 
   public function setSynopsis($synopsis) {
     $this->synopsis = $synopsis;
     return $this;
   }
 
   public function setTagline($tagline) {
     $this->tagline = $tagline;
     return $this;
   }
 
   public function printHelpAndExit() {
     echo $this->renderHelp();
     exit(self::PARSE_ERROR_CODE);
   }
 
   public function renderHelp() {
     $out = array();
     $more = array();
 
     if ($this->bin) {
       $out[] = $this->format('**%s**', pht('NAME'));
       $name = $this->indent(6, '**%s**', basename($this->bin));
       if ($this->tagline) {
         $name .= $this->format(' - '.$this->tagline);
       }
       $out[] = $name;
       $out[] = null;
     }
 
     if ($this->synopsis) {
       $out[] = $this->format('**%s**', pht('SYNOPSIS'));
       $out[] = $this->indent(6, $this->synopsis);
       $out[] = null;
     }
 
     $workflows = $this->helpWorkflows;
     if ($workflows === null) {
       $workflows = $this->workflows;
     }
 
     if ($workflows) {
       $has_help = false;
       $out[] = $this->format('**%s**', pht('WORKFLOWS'));
       $out[] = null;
       $flows = $workflows;
       ksort($flows);
       foreach ($flows as $workflow) {
         if ($workflow->getName() == 'help') {
           $has_help = true;
         }
         $out[] = $this->renderWorkflowHelp(
           $workflow->getName(),
           $show_details = false);
       }
       if ($has_help) {
         $more[] = pht(
           'Use **%s** __command__ for a detailed command reference.', 'help');
       }
     }
 
     $specs = $this->renderArgumentSpecs($this->specs);
     if ($specs) {
       $out[] = $this->format('**%s**', pht('OPTION REFERENCE'));
       $out[] = null;
       $out[] = $specs;
     }
 
     // If we have standard options but no --show-standard-options, print out
     // a quick hint about it.
     if (!empty($this->specs['show-standard-options']) &&
         !$this->getArg('show-standard-options')) {
       $more[] = pht(
         'Use __%s__ to show additional options.', '--show-standard-options');
     }
 
     $out[] = null;
 
     if ($more) {
       foreach ($more as $hint) {
         $out[] = $this->indent(0, $hint);
       }
       $out[] = null;
     }
 
     return implode("\n", $out);
   }
 
   public function renderWorkflowHelp(
     $workflow_name,
     $show_details = false) {
 
     $out = array();
 
     $indent = ($show_details ? 0 : 6);
 
     $workflows = $this->helpWorkflows;
     if ($workflows === null) {
       $workflows = $this->workflows;
     }
 
     $workflow = idx($workflows, strtolower($workflow_name));
     if (!$workflow) {
       $out[] = $this->indent(
         $indent,
         pht('There is no **%s** workflow.', $workflow_name));
     } else {
       $out[] = $this->indent($indent, $workflow->getExamples());
-      $out[] = $this->indent($indent, $workflow->getSynopsis());
+
+      $synopsis = $workflow->getSynopsis();
+      if ($synopsis !== null) {
+        $out[] = $this->indent($indent, $workflow->getSynopsis());
+      }
+
       if ($show_details) {
         $full_help = $workflow->getHelp();
         if ($full_help) {
           $out[] = null;
           $out[] = $this->indent($indent, $full_help);
         }
         $specs = $this->renderArgumentSpecs($workflow->getArguments());
         if ($specs) {
           $out[] = null;
           $out[] = $specs;
         }
       }
     }
 
     $out[] = null;
 
     return implode("\n", $out);
   }
 
   public function printUsageException(PhutilArgumentUsageException $ex) {
     $message = tsprintf(
       "**%s** %B\n",
       pht('Usage Exception:'),
       $ex->getMessage());
 
     $this->logMessage($message);
   }
 
 
   private function logMessage($message) {
     fwrite(STDERR, $message);
   }
 
 
 /* -(  Internals  )---------------------------------------------------------- */
 
 
   private function filterWildcardArgv(array $argv) {
     foreach ($argv as $key => $value) {
       if ($value == '--') {
         unset($argv[$key]);
         break;
       } else if (
         is_string($value) &&
         !strncmp($value, '-', 1) &&
         strlen($value) > 1) {
 
         throw new PhutilArgumentUsageException(
           pht(
             'Argument "%s" is unrecognized. Use "%s" to indicate '.
             'the end of flags.',
             $value,
             '--'));
       }
     }
     return array_values($argv);
   }
 
   private function mergeSpecs(array $specs) {
 
     $short_map = mpull($this->specs, null, 'getShortAlias');
     unset($short_map[null]);
 
     $wildcard = null;
     foreach ($this->specs as $spec) {
       if ($spec->getWildcard()) {
         $wildcard = $spec;
         break;
       }
     }
 
     foreach ($specs as $spec) {
       $spec->validate();
       $name = $spec->getName();
 
       if (isset($this->specs[$name])) {
         throw new PhutilArgumentSpecificationException(
           pht(
             'Two argument specifications have the same name ("%s").',
             $name));
       }
 
       $short = $spec->getShortAlias();
       if ($short) {
         if (isset($short_map[$short])) {
           throw new PhutilArgumentSpecificationException(
             pht(
               'Two argument specifications have the same short alias ("%s").',
               $short));
         }
         $short_map[$short] = $spec;
       }
 
       if ($spec->getWildcard()) {
         if ($wildcard) {
           throw new PhutilArgumentSpecificationException(
             pht(
               'Two argument specifications are marked as wildcard arguments. '.
               'You can have a maximum of one wildcard argument.'));
         } else {
           $wildcard = $spec;
         }
       }
 
       $this->specs[$name] = $spec;
     }
 
     foreach ($this->specs as $name => $spec) {
       foreach ($spec->getConflicts() as $conflict => $reason) {
         if (empty($this->specs[$conflict])) {
           throw new PhutilArgumentSpecificationException(
             pht(
               'Argument "%s" conflicts with unspecified argument "%s".',
               $name,
               $conflict));
         }
         if ($conflict == $name) {
           throw new PhutilArgumentSpecificationException(
             pht(
               'Argument "%s" conflicts with itself!',
               $name));
         }
       }
     }
 
   }
 
   private function renderArgumentSpecs(array $specs) {
     foreach ($specs as $key => $spec) {
       if ($spec->getWildcard()) {
         unset($specs[$key]);
       }
     }
 
     $out = array();
 
     $no_standard_options =
       !empty($this->specs['show-standard-options']) &&
       !$this->getArg('show-standard-options');
 
     $specs = msort($specs, 'getName');
     foreach ($specs as $spec) {
       if ($spec->getStandard() && $no_standard_options) {
         // If this is a standard argument and the user didn't pass
         // --show-standard-options, skip it.
         continue;
       }
       $name = $this->indent(6, '__--%s__', $spec->getName());
       $short = null;
       if ($spec->getShortAlias()) {
         $short = $this->format(', __-%s__', $spec->getShortAlias());
       }
       if ($spec->getParamName()) {
         $param = $this->format(' __%s__', $spec->getParamName());
         $name .= $param;
         if ($short) {
           $short .= $param;
         }
       }
       $out[] = $name.$short;
       $out[] = $this->indent(10, $spec->getHelp());
       $out[] = null;
     }
 
     return implode("\n", $out);
   }
 
   private function format($str /* , ... */) {
     $args = func_get_args();
     return call_user_func_array(
       'phutil_console_format',
       $args);
   }
 
   private function indent($level, $str /* , ... */) {
     $args = func_get_args();
     $args = array_slice($args, 1);
     $text = call_user_func_array(array($this, 'format'), $args);
     return phutil_console_wrap($text, $level);
   }
 
   /**
    * @phutil-external-symbol function xhprof_disable
    */
   public function shutdownProfiler() {
     $data = xhprof_disable();
     $data = json_encode($data);
     Filesystem::writeFile($this->getArg('xprofile'), $data);
   }
 
   public static function isTraceModeEnabled() {
     return self::$traceModeEnabled;
   }
 
   private function raiseUnknownWorkflow($flow, array $maybe) {
     if ($maybe) {
       sort($maybe);
 
       $maybe_list = id(new PhutilConsoleList())
         ->setWrap(false)
         ->setBullet(null)
         ->addItems($maybe)
         ->drawConsoleString();
 
       $message = tsprintf(
         "%B\n%B",
         pht(
           'Invalid command "%s". Did you mean:',
           $flow),
         $maybe_list);
     } else {
       $names = mpull($this->workflows, 'getName');
       sort($names);
 
       $message = tsprintf(
         '%B',
         pht(
           'Invalid command "%s". Valid commands are: %s.',
           $flow,
           implode(', ', $names)));
     }
 
     if (isset($this->workflows['help'])) {
       $binary = basename($this->bin);
       $message = tsprintf(
         "%B\n%s",
         $message,
         pht(
           'For details on available commands, run "%s".',
           "{$binary} help"));
     }
 
     throw new PhutilArgumentUsageException($message);
   }
 
   private function shouldAutocorrect() {
     return !phutil_is_noninteractive();
   }
 
 }
diff --git a/src/symbols/PhutilClassMapQuery.php b/src/symbols/PhutilClassMapQuery.php
index 4b947a61..62d567d6 100644
--- a/src/symbols/PhutilClassMapQuery.php
+++ b/src/symbols/PhutilClassMapQuery.php
@@ -1,338 +1,338 @@
 <?php
 
 /**
  * Load a map of concrete subclasses of some abstract parent class.
  *
  * libphutil is extensively modular through runtime introspection of class
  * maps. This method makes querying class maps easier.
  *
  * There are several common patterns used with modular class maps:
  *
  *   - A `getUniqueKey()` method which returns a unique scalar key identifying
  *     the class.
  *   - An `expandVariants()` method which potentially returns multiple
  *     instances of the class with different configurations.
  *   - A `getSortName()` method which sorts results.
  *   - Caching of the map.
  *
  * This class provides support for these mechanisms.
  *
  * Using the unique key mechanism with @{method:setUniqueMethod} allows you to
  * use a more human-readable, storage-friendly key to identify objects, allows
  * classes to be freely renamed, and enables variant expansion.
  *
  * Using the expansion mechanism with @{method:setExpandMethod} allows you to
  * have multiple similar modular instances, or configuration-driven instances.
  *
  * Even if they have no immediate need for either mechanism, class maps should
  * generally provide unique keys in their initial design so they are more
  * flexible later on. Adding unique keys later can require database migrations,
  * while adding an expansion mechanism is trivial if a class map already has
  * unique keys.
  *
  * This class automatically caches class maps and does not need to be wrapped
  * in caching logic.
  *
  * @task config Configuring the Query
  * @task exec Executing the Query
  * @task cache Managing the Map Cache
  */
 final class PhutilClassMapQuery extends Phobject {
 
   private $ancestorClass;
   private $expandMethod;
   private $filterMethod;
   private $filterNull = false;
   private $uniqueMethod;
   private $sortMethod;
   private $continueOnFailure;
 
   // NOTE: If you add more configurable properties here, make sure that
   // cache key construction in getCacheKey() is updated properly.
 
   private static $cache = array();
 
 
 /* -(  Configuring the Query  )---------------------------------------------- */
 
 
   /**
    * Set the ancestor class or interface name to load the concrete descendants
    * of.
    *
    * @param string Ancestor class or interface name.
    * @return this
    * @task config
    */
   public function setAncestorClass($class) {
     $this->ancestorClass = $class;
     return $this;
   }
 
 
   /**
    * Provide a method to select a unique key for each instance.
    *
    * If you provide a method here, the map will be keyed with these values,
    * instead of with class names. Exceptions will be raised if entries are
    * not unique.
    *
    * You must provide a method here to use @{method:setExpandMethod}.
    *
    * @param string  Name of the unique key method.
    * @param bool    If true, then classes which return `null` will be filtered
    *                from the results.
    * @return this
    * @task config
    */
   public function setUniqueMethod($unique_method, $filter_null = false) {
     $this->uniqueMethod = $unique_method;
     $this->filterNull   = $filter_null;
     return $this;
   }
 
 
   /**
    * Provide a method to expand each concrete subclass into available instances.
    *
    * With some class maps, each class is allowed to provide multiple entries
    * in the map by returning alternatives from some method with a default
    * implementation like this:
    *
    *   public function generateVariants() {
    *     return array($this);
    *   }
    *
    * For example, a "color" class may really generate and configure several
    * instances in the final class map:
    *
    *   public function generateVariants() {
    *     return array(
    *       self::newColor('red'),
    *       self::newColor('green'),
    *       self::newColor('blue'),
    *     );
    *   }
    *
    * This allows multiple entires in the final map to share an entire
    * implementation, rather than requiring that they each have their own unique
    * subclass.
    *
    * This pattern is most useful if several variants are nearly identical (so
    * the stub subclasses would be essentially empty) or the available variants
    * are driven by configuration.
    *
    * If a class map uses this pattern, it must also provide a unique key for
    * each instance with @{method:setUniqueMethod}.
    *
    * @param string Name of the expansion method.
    * @return this
    * @task config
    */
   public function setExpandMethod($expand_method) {
     $this->expandMethod = $expand_method;
     return $this;
   }
 
 
   /**
    * Provide a method to sort the final map.
    *
    * The map will be sorted using @{function:msort} and passing this method
    * name.
    *
    * @param string Name of the sorting method.
    * @return this
    * @task config
    */
   public function setSortMethod($sort_method) {
     $this->sortMethod = $sort_method;
     return $this;
   }
 
 
   /**
    * Provide a method to filter the map.
    *
    * @param string Name of the filtering method.
    * @return this
    * @task config
    */
   public function setFilterMethod($filter_method) {
     $this->filterMethod = $filter_method;
     return $this;
   }
 
   public function setContinueOnFailure($continue) {
     $this->continueOnFailure = $continue;
     return $this;
   }
 
 /* -(  Executing the Query  )------------------------------------------------ */
 
 
   /**
    * Execute the query as configured.
    *
    * @return map<string, object> Realized class map.
    * @task exec
    */
   public function execute() {
     $cache_key = $this->getCacheKey();
 
     if (!isset(self::$cache[$cache_key])) {
       self::$cache[$cache_key] = $this->loadMap();
     }
 
     return self::$cache[$cache_key];
   }
 
 
   /**
    * Delete all class map caches.
    *
    * @return void
    * @task exec
    */
   public static function deleteCaches() {
     self::$cache = array();
   }
 
 
   /**
    * Generate the core query results.
    *
    * This method is used to fill the cache.
    *
    * @return map<string, object> Realized class map.
    * @task exec
    */
   private function loadMap() {
     $ancestor = $this->ancestorClass;
     if (!strlen($ancestor)) {
       throw new PhutilInvalidStateException('setAncestorClass');
     }
 
     if (!class_exists($ancestor) && !interface_exists($ancestor)) {
       throw new Exception(
         pht(
           'Trying to execute a class map query for descendants of class '.
           '"%s", but no such class or interface exists.',
           $ancestor));
     }
 
     $expand = $this->expandMethod;
     $filter = $this->filterMethod;
     $unique = $this->uniqueMethod;
     $sort = $this->sortMethod;
 
-    if (strlen($expand)) {
-      if (!strlen($unique)) {
+    if ($expand !== null) {
+      if ($unique === null) {
         throw new Exception(
           pht(
             'Trying to execute a class map query for descendants of class '.
             '"%s", but the query specifies an "expand method" ("%s") without '.
             'specifying a "unique method". Class maps which support expansion '.
             'must have unique keys.',
             $ancestor,
             $expand));
       }
     }
 
     $objects = id(new PhutilSymbolLoader())
       ->setAncestorClass($ancestor)
       ->setContinueOnFailure($this->continueOnFailure)
       ->loadObjects();
 
     // Apply the "expand" mechanism, if it is configured.
-    if (strlen($expand)) {
+    if ($expand !== null) {
       $list = array();
       foreach ($objects as $object) {
         foreach (call_user_func(array($object, $expand)) as $instance) {
           $list[] = $instance;
         }
       }
     } else {
       $list = $objects;
     }
 
     // Apply the "unique" mechanism, if it is configured.
-    if (strlen($unique)) {
+    if ($unique !== null) {
       $map = array();
       foreach ($list as $object) {
         $key = call_user_func(array($object, $unique));
 
         if ($key === null && $this->filterNull) {
           continue;
         }
 
         if (empty($map[$key])) {
           $map[$key] = $object;
           continue;
         }
 
         throw new Exception(
           pht(
             'Two objects (of classes "%s" and "%s", descendants of ancestor '.
             'class "%s") returned the same key from "%s" ("%s"), but each '.
             'object in this class map must be identified by a unique key.',
             get_class($object),
             get_class($map[$key]),
             $ancestor,
             $unique.'()',
             $key));
       }
     } else {
       $map = $list;
     }
 
     // Apply the "filter" mechanism, if it is configured.
-    if (strlen($filter)) {
+    if ($filter !== null) {
       $map = mfilter($map, $filter);
     }
 
     // Apply the "sort" mechanism, if it is configured.
-    if (strlen($sort)) {
+    if ($sort !== null) {
       if ($map) {
         // The "sort" method may return scalars (which we want to sort with
         // "msort()"), or may return PhutilSortVector objects (which we want
         // to sort with "msortv()").
         $item = call_user_func(array(head($map), $sort));
 
         // Since we may be early in the stack, use a string to avoid triggering
         // autoload in old versions of PHP.
         $vector_class = 'PhutilSortVector';
         if ($item instanceof $vector_class) {
           $map = msortv($map, $sort);
         } else {
           $map = msort($map, $sort);
         }
       }
     }
 
     return $map;
   }
 
 
 /* -(  Managing the Map Cache  )--------------------------------------------- */
 
 
   /**
    * Return a cache key for this query.
    *
    * @return string Cache key.
    * @task cache
    */
   public function getCacheKey() {
     $parts = array(
       $this->ancestorClass,
       $this->uniqueMethod,
       $this->filterNull,
       $this->expandMethod,
       $this->filterMethod,
       $this->sortMethod,
     );
     return implode(':', $parts);
   }
 
 }
diff --git a/src/workflow/ArcanistWorkflow.php b/src/workflow/ArcanistWorkflow.php
index 08362db2..43ed0c0e 100644
--- a/src/workflow/ArcanistWorkflow.php
+++ b/src/workflow/ArcanistWorkflow.php
@@ -1,2470 +1,2470 @@
 <?php
 
 /**
  * Implements a runnable command, like "arc diff" or "arc help".
  *
  * = Managing Conduit =
  *
  * Workflows have the builtin ability to open a Conduit connection to a
  * Phabricator installation, so methods can be invoked over the API. Workflows
  * may either not need this (e.g., "help"), or may need a Conduit but not
  * authentication (e.g., calling only public APIs), or may need a Conduit and
  * authentication (e.g., "arc diff").
  *
  * To specify that you need an //unauthenticated// conduit, override
  * @{method:requiresConduit} to return ##true##. To specify that you need an
  * //authenticated// conduit, override @{method:requiresAuthentication} to
  * return ##true##. You can also manually invoke @{method:establishConduit}
  * and/or @{method:authenticateConduit} later in a workflow to upgrade it.
  * Once a conduit is open, you can access the client by calling
  * @{method:getConduit}, which allows you to invoke methods. You can get
  * verified information about the user identity by calling @{method:getUserPHID}
  * or @{method:getUserName} after authentication occurs.
  *
  * = Scratch Files =
  *
  * Arcanist workflows can read and write 'scratch files', which are temporary
  * files stored in the project that persist across commands. They can be useful
  * if you want to save some state, or keep a copy of a long message the user
  * entered if something goes wrong.
  *
  *
  * @task  conduit   Conduit
  * @task  scratch   Scratch Files
  * @task  phabrep   Phabricator Repositories
  */
 abstract class ArcanistWorkflow extends Phobject {
 
   const COMMIT_DISABLE = 0;
   const COMMIT_ALLOW = 1;
   const COMMIT_ENABLE = 2;
 
   private $commitMode = self::COMMIT_DISABLE;
 
   private $conduit;
   private $conduitURI;
   private $conduitCredentials;
   private $conduitAuthenticated;
   private $conduitTimeout;
 
   private $userPHID;
   private $userName;
   private $repositoryAPI;
   private $configurationManager;
   private $arguments = array();
   private $command;
 
   private $stashed;
   private $shouldAmend;
 
   private $projectInfo;
   private $repositoryInfo;
   private $repositoryReasons;
   private $repositoryRef;
 
   private $arcanistConfiguration;
   private $parentWorkflow;
   private $workingDirectory;
   private $repositoryVersion;
 
   private $changeCache = array();
   private $conduitEngine;
 
   private $toolset;
   private $runtime;
   private $configurationEngine;
   private $configurationSourceList;
 
   private $promptMap;
 
   final public function setToolset(ArcanistToolset $toolset) {
     $this->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 !== null) {
       if (!($information instanceof ArcanistWorkflowInformation)) {
         throw new Exception(
           pht(
             'Expected workflow ("%s", of class "%s") to return an '.
             '"ArcanistWorkflowInformation" object from call to '.
             '"getWorkflowInformation()", got %s.',
             $this->getWorkflowName(),
             get_class($this),
             phutil_describe_type($information)));
       }
     }
 
     if ($information) {
       $synopsis = $information->getSynopsis();
-      if (strlen($synopsis)) {
+      if ($synopsis !== null) {
         $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 public function newLegacyPhutilWorkflow() {
     $phutil_workflow = id(new ArcanistPhutilWorkflow())
       ->setName($this->getWorkflowName());
 
     $arguments = $this->getArguments();
 
     $specs = array();
     foreach ($arguments as $key => $argument) {
       if ($key == '*') {
         $key = $argument;
         $argument = array(
           'wildcard' => true,
         );
       }
 
       unset($argument['paramtype']);
       unset($argument['supports']);
       unset($argument['nosupport']);
       unset($argument['passthru']);
       unset($argument['conflict']);
 
       $spec = array(
         'name' => $key,
       ) + $argument;
 
       $specs[] = $spec;
     }
 
     $phutil_workflow->setArguments($specs);
 
     $synopses = $this->getCommandSynopses();
     $phutil_workflow->setSynopsis($synopses);
 
     $help = $this->getCommandHelp();
     if (strlen($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 public 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) {
     $source_list = $this->getConfigurationSourceList();
     if ($source_list) {
       $value_list = $source_list->getStorageValueList($key);
       if ($value_list) {
         return last($value_list)->getValue();
       }
 
       return null;
     }
 
     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;
   }
 
   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 getCompleteArgumentSpecification() {
     $spec = $this->getArguments();
     $arc_config = $this->getArcanistConfiguration();
     $command = $this->getCommand();
     $spec += $arc_config->getCustomArgumentsForCommand($command);
 
     return $spec;
   }
 
   final public function parseArguments(array $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() {
     $configuration_engine = $this->getConfigurationEngine();
 
     // TOOLSETS: Remove this once all workflows are toolset workflows.
     if (!$configuration_engine) {
       throw new Exception(
         pht(
           'This workflow has not yet been updated to Toolsets and can '.
           'not retrieve a modern WorkingCopy object. Use '.
           '"getWorkingCopyIdentity()" to retrieve a previous-generation '.
           'object.'));
     }
 
     return $configuration_engine->getWorkingCopy();
   }
 
 
   final public function getWorkingCopyIdentity() {
     $configuration_engine = $this->getConfigurationEngine();
     if ($configuration_engine) {
       $working_copy = $configuration_engine->getWorkingCopy();
       $working_path = $working_copy->getWorkingDirectory();
 
       return ArcanistWorkingCopyIdentity::newFromPath($working_path);
     }
 
     $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() {
     $configuration_engine = $this->getConfigurationEngine();
     if ($configuration_engine) {
       $working_copy = $configuration_engine->getWorkingCopy();
       return $working_copy->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'))
           ->setTaskMessage(pht(
             'Supply commit message for uncommitted changes, then save and '.
             'exit.'))
           ->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),
     ));
   }
 
   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(
         "<bg:blue>** %s **</bg> %s\n",
         $title,
         $message));
   }
 
   final public function writeWarn($title, $message) {
     $this->writeStatusMessage(
       phutil_console_format(
         "<bg:yellow>** %s **</bg> %s\n",
         $title,
         $message));
   }
 
   final public function writeOkay($title, $message) {
     $this->writeStatusMessage(
       phutil_console_format(
         "<bg:green>** %s **</bg> %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->getWorkingCopyIdentity();
       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 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<string> 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()
         ->newFuture($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).');
     }
 
     // TODO: Swap this for a RemoteRefQuery.
 
     $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->getWorkingCopyIdentity();
     $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->getWorkingCopyIdentity();
     $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();
 
     // The "browser" may actually be a list of arguments.
     if (!is_array($browser)) {
       $browser = array($browser);
     }
 
     foreach ($uris as $uri) {
       $err = phutil_passthru('%LR %R', $browser, $uri);
       if ($err) {
         throw new ArcanistUsageException(
           pht(
             'Failed to open URI "%s" in browser ("%s"). '.
             'Check your "browser" config option.',
             $uri,
             implode(' ', $browser)));
       }
     }
   }
 
   private function getBrowserCommand() {
     $config = $this->getConfigFromAnySource('browser');
     if ($config) {
       return $config;
     }
 
     if (phutil_is_windows()) {
       // See T13504. We now use "bypass_shell", so "start" alone is no longer
       // a valid binary to invoke directly.
       return array(
         'cmd',
         '/c',
         'start',
       );
     }
 
     $candidates = array(
       'sensible-browser' => array('sensible-browser'),
       'xdg-open' => array('xdg-open'),
       'open' => array('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 => $argv) {
       if (Filesystem::binaryExists($cmd)) {
         return $argv;
       }
     }
 
     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 public function getRepositoryRef() {
     $configuration_engine = $this->getConfigurationEngine();
     if ($configuration_engine) {
       // This is a toolset workflow and can always build a repository ref.
     } else {
       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);
   }
 
   public function canHandleSignal($signo) {
     return false;
   }
 
   public function handleSignal($signo) {
     return;
   }
 
   final public function newCommand(PhutilExecutableFuture $future) {
     return id(new ArcanistCommand())
       ->setLogEngine($this->getLogEngine())
       ->setExecutableFuture($future);
   }
 
   final public function loadHardpoints(
     $objects,
     $requests) {
     return $this->getRuntime()->loadHardpoints($objects, $requests);
   }
 
   protected function newPrompts() {
     return array();
   }
 
   protected function newPrompt($key) {
     return id(new ArcanistPrompt())
       ->setWorkflow($this)
       ->setKey($key);
   }
 
   public function hasPrompt($key) {
     $map = $this->getPromptMap();
     return isset($map[$key]);
   }
 
   public function getPromptMap() {
     if ($this->promptMap === null) {
       $prompts = $this->newPrompts();
       assert_instances_of($prompts, 'ArcanistPrompt');
 
       // TODO: Move this somewhere modular.
 
       $prompts[] = $this->newPrompt('arc.state.stash')
         ->setDescription(
           pht(
             'Prompts the user to stash changes and continue when the '.
             'working copy has untracked, uncommitted, or unstaged '.
             'changes.'));
 
       // TODO: Swap to ArrayCheck?
 
       $map = array();
       foreach ($prompts as $prompt) {
         $key = $prompt->getKey();
 
         if (isset($map[$key])) {
           throw new Exception(
             pht(
               'Workflow ("%s") generates two prompts with the same '.
               'key ("%s"). Each prompt a workflow generates must have a '.
               'unique key.',
               get_class($this),
               $key));
         }
 
         $map[$key] = $prompt;
       }
 
       $this->promptMap = $map;
     }
 
     return $this->promptMap;
   }
 
   final public function getPrompt($key) {
     $map = $this->getPromptMap();
 
     $prompt = idx($map, $key);
     if (!$prompt) {
       throw new Exception(
         pht(
           'Workflow ("%s") is requesting a prompt ("%s") but it did not '.
           'generate any prompt with that name in "newPrompts()".',
           get_class($this),
           $key));
     }
 
     return clone $prompt;
   }
 
   final protected function getSymbolEngine() {
     return $this->getRuntime()->getSymbolEngine();
   }
 
   final protected function getViewer() {
     return $this->getRuntime()->getViewer();
   }
 
   final protected function readStdin() {
     $log = $this->getLogEngine();
     $log->writeWaitingForInput();
 
     // NOTE: We can't just "file_get_contents()" here because signals don't
     // interrupt it. If the user types "^C", we want to interrupt the read.
 
     $raw_handle = fopen('php://stdin', 'rb');
     $stdin = new PhutilSocketChannel($raw_handle);
 
     while ($stdin->update()) {
       PhutilChannel::waitForAny(array($stdin));
     }
 
     return $stdin->read();
   }
 
   final public function getAbsoluteURI($raw_uri) {
     // TODO: "ArcanistRevisionRef", at least, may return a relative URI.
     // If we get a relative URI, guess the correct absolute URI based on
     // the Conduit URI. This might not be correct for Conduit over SSH.
 
     $raw_uri = new PhutilURI($raw_uri);
     if (!strlen($raw_uri->getDomain())) {
       $base_uri = $this->getConduitEngine()
         ->getConduitURI();
 
       $raw_uri = id(new PhutilURI($base_uri))
         ->setPath($raw_uri->getPath());
     }
 
     $raw_uri = phutil_string_cast($raw_uri);
 
     return $raw_uri;
   }
 
   final public function writeToPager($corpus) {
     $is_tty = (function_exists('posix_isatty') && posix_isatty(STDOUT));
 
     if (!$is_tty) {
       echo $corpus;
     } else {
       $pager = $this->getConfig('pager');
 
       if (!$pager) {
         $pager = array('less', '-R', '--');
       }
 
       // Try to show the content through a pager.
       $err = id(new PhutilExecPassthru('%Ls', $pager))
         ->write($corpus)
         ->resolve();
 
       // If the pager exits with an error, print the content normally.
       if ($err) {
         echo $corpus;
       }
     }
 
     return $this;
   }
 
 }