gclient: Preserve ANSI color codes when calling hooks.

If a hook prints error/warning output that's color-coded, gclient
will cause the coloring to be disabled since those hooks aren't
called directly from a terminal.

By emulating a terminal when launching subprocs from gclient, we can
convince them to keep the color escape codes.

LED builds with both //third_party/depot_tools rolled to this CL, as
well as depot_tools in the recipe bundle rolled to this CL:
linux: https://ci.chromium.org/swarming/task/4e40237985888310
mac: https://ci.chromium.org/swarming/task/4e4023ea0c829710
win: https://ci.chromium.org/swarming/task/4e4024612e03dc10

Bug: 1034063
Change-Id: I4150f66ef215ece06f4c32482dcd4ded14eb1bfe
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/tools/depot_tools/+/2368435
Reviewed-by: Dirk Pranke <dpranke@google.com>
Commit-Queue: Ben Pastene <bpastene@chromium.org>
diff --git a/gclient_utils.py b/gclient_utils.py
index c62d589..dcf5222 100644
--- a/gclient_utils.py
+++ b/gclient_utils.py
@@ -10,6 +10,7 @@
 import collections
 import contextlib
 import datetime
+import errno
 import functools
 import io
 import logging
@@ -585,9 +586,19 @@
   sleep_interval = RETRY_INITIAL_SLEEP
   run_cwd = kwargs.get('cwd', os.getcwd())
   for attempt in range(RETRY_MAX + 1):
+    # If our stdout is a terminal, then pass in a psuedo-tty pipe to our
+    # subprocess when filtering its output. This makes the subproc believe
+    # it was launched from a terminal, which will preserve ANSI color codes.
+    if sys.stdout.isatty():
+      pipe_reader, pipe_writer = os.openpty()
+    else:
+      pipe_reader, pipe_writer = os.pipe()
+
     kid = subprocess2.Popen(
-        args, bufsize=0, stdout=subprocess2.PIPE, stderr=subprocess2.STDOUT,
+        args, bufsize=0, stdout=pipe_writer, stderr=subprocess2.STDOUT,
         **kwargs)
+    # Close the write end of the pipe once we hand it off to the child proc.
+    os.close(pipe_writer)
 
     GClientChildren.add(kid)
 
@@ -608,7 +619,14 @@
     try:
       line_start = None
       while True:
-        in_byte = kid.stdout.read(1)
+        try:
+          in_byte = os.read(pipe_reader, 1)
+        except (IOError, OSError) as e:
+          if e.errno == errno.EIO:
+            # An errno.EIO means EOF?
+            in_byte = None
+          else:
+            raise e
         is_newline = in_byte in (b'\n', b'\r')
         if not in_byte:
           break
@@ -629,8 +647,8 @@
       if line_start is not None:
         filter_line(command_output, line_start)
 
+      os.close(pipe_reader)
       rv = kid.wait()
-      kid.stdout.close()
 
       # Don't put this in a 'finally,' since the child may still run if we get
       # an exception.