gclient: add use_relative_hooks

When a recursive dependency has use_relative_paths it also makes sense
to have the hooks working directory by the dependency's directory.
Otherwise if a hook uses one of the relative dependencies it is impossible
to know which path prefix to use.

However we cannot change the behavior of hooks with use_relative_paths
because it would break existing projects that use_relative_paths but
hardcoded the prefix for hooks. Instead we add a second boolean,
use_relative_hooks that triggers the behavior.

Adds tests for the new behavior and a test for existing interactio
between hooks and recursedeps.

BUG=chromium:875245

Change-Id: Ie4c526baa425ff887b3be54e0feca7c597ededec
Reviewed-on: https://chromium-review.googlesource.com/1213327
Commit-Queue: Corentin Wallez <cwallez@chromium.org>
Reviewed-by: Edward Lesmes <ehmaldonado@chromium.org>
diff --git a/gclient.py b/gclient.py
index f65af57..1637190 100755
--- a/gclient.py
+++ b/gclient.py
@@ -151,7 +151,7 @@
   """Descriptor of command ran before/after sync or on demand."""
 
   def __init__(self, action, pattern=None, name=None, cwd=None, condition=None,
-               variables=None, verbose=False):
+               variables=None, verbose=False, cwd_base=None):
     """Constructor.
 
     Arguments:
@@ -169,9 +169,11 @@
     self._condition = condition
     self._variables = variables
     self._verbose = verbose
+    self._cwd_base = cwd_base
 
   @staticmethod
-  def from_dict(d, variables=None, verbose=False, conditions=None):
+  def from_dict(d, variables=None, verbose=False, conditions=None,
+                cwd_base=None):
     """Creates a Hook instance from a dict like in the DEPS file."""
     # Merge any local and inherited conditions.
     gclient_eval.UpdateCondition(d, 'and', conditions)
@@ -183,7 +185,8 @@
         d.get('condition'),
         variables=variables,
         # Always print the header if not printing to a TTY.
-        verbose=verbose or not setup_color.IS_TTY)
+        verbose=verbose or not setup_color.IS_TTY,
+        cwd_base=cwd_base)
 
   @property
   def action(self):
@@ -201,6 +204,13 @@
   def condition(self):
     return self._condition
 
+  @property
+  def effective_cwd(self):
+    cwd = self._cwd_base
+    if self._cwd:
+      cwd = os.path.join(cwd, self._cwd)
+    return cwd
+
   def matches(self, file_list):
     """Returns true if the pattern matches any of files in the list."""
     if not self._pattern:
@@ -208,7 +218,7 @@
     pattern = re.compile(self._pattern)
     return bool([f for f in file_list if pattern.search(f)])
 
-  def run(self, root):
+  def run(self):
     """Executes the hook's command (provided the condition is met)."""
     if (self._condition and
         not gclient_eval.EvaluateCondition(self._condition, self._variables)):
@@ -224,13 +234,10 @@
     elif cmd[0] == 'vpython' and _detect_host_os() == 'win':
       cmd[0] += '.bat'
 
-    cwd = root
-    if self._cwd:
-      cwd = os.path.join(cwd, self._cwd)
     try:
       start_time = time.time()
       gclient_utils.CheckCallAndFilterAndHeader(
-          cmd, cwd=cwd, always=self._verbose)
+          cmd, cwd=self.effective_cwd, always=self._verbose)
     except (gclient_utils.Error, subprocess2.CalledProcessError) as e:
       # Use a discrete exit status code of 2 to indicate that a hook action
       # failed.  Users of this script may wish to treat hook action failures
@@ -755,6 +762,18 @@
     deps_to_add = self._deps_to_objects(
         self._postprocess_deps(deps, rel_prefix), use_relative_paths)
 
+    # compute which working directory should be used for hooks
+    use_relative_hooks = local_scope.get('use_relative_hooks', False)
+    hooks_cwd = self.root.root_dir
+    if use_relative_hooks:
+      if not use_relative_paths:
+        raise gclient_utils.Error(
+            'ParseDepsFile(%s): use_relative_hooks must be used with '
+            'use_relative_paths' % self.name)
+      hooks_cwd = os.path.join(hooks_cwd, self.name)
+      logging.warning('Updating hook base working directory to %s.',
+                      hooks_cwd)
+
     # override named sets of hooks by the custom hooks
     hooks_to_run = []
     hook_names_to_suppress = [c.get('name', '') for c in self.custom_hooks]
@@ -770,11 +789,12 @@
     if self.should_recurse:
       self._pre_deps_hooks = [
           Hook.from_dict(hook, variables=self.get_vars(), verbose=True,
-                         conditions=self.condition)
+                         conditions=self.condition, cwd_base=hooks_cwd)
           for hook in local_scope.get('pre_deps_hooks', [])
       ]
 
-    self.add_dependencies_and_close(deps_to_add, hooks_to_run)
+    self.add_dependencies_and_close(deps_to_add, hooks_to_run,
+                                    hooks_cwd=hooks_cwd)
     logging.info('ParseDepsFile(%s) done' % self.name)
 
   def _get_option(self, attr, default):
@@ -783,15 +803,18 @@
       obj = obj.parent
     return getattr(obj._options, attr, default)
 
-  def add_dependencies_and_close(self, deps_to_add, hooks):
+  def add_dependencies_and_close(self, deps_to_add, hooks, hooks_cwd=None):
     """Adds the dependencies, hooks and mark the parsing as done."""
+    if hooks_cwd == None:
+      hooks_cwd = self.root.root_dir
+
     for dep in deps_to_add:
       if dep.verify_validity():
         self.add_dependency(dep)
     self._mark_as_parsed([
         Hook.from_dict(
             h, variables=self.get_vars(), verbose=self.root._options.verbose,
-            conditions=self.condition)
+            conditions=self.condition, cwd_base=hooks_cwd)
         for h in hooks
     ])
 
@@ -1021,7 +1044,7 @@
     for hook in hooks:
       if progress:
         progress.update(extra=hook.name or '')
-      hook.run(self.root.root_dir)
+      hook.run()
     if progress:
       progress.end()
 
@@ -1034,7 +1057,7 @@
       assert not s.processed
     self._pre_deps_hooks_ran = True
     for hook in self.pre_deps_hooks:
-      hook.run(self.root.root_dir)
+      hook.run()
 
   def GetCipdRoot(self):
     if self.root is self:
@@ -2257,9 +2280,10 @@
       s.append('    "pattern": "%s",' % hook.pattern)
     if hook.condition is not None:
       s.append('    "condition": %r,' % hook.condition)
+    # Flattened hooks need to be written relative to the root gclient dir
+    cwd = os.path.relpath(os.path.normpath(hook.effective_cwd))
     s.extend(
-        # Hooks run in the parent directory of their dep.
-        ['    "cwd": "%s",' % os.path.normpath(os.path.dirname(dep.name))] +
+        ['    "cwd": "%s",' % cwd] +
         ['    "action": ['] +
         ['        "%s",' % arg for arg in hook.action] +
         ['    ]', '  },', '']
@@ -2286,9 +2310,10 @@
         s.append('      "pattern": "%s",' % hook.pattern)
       if hook.condition is not None:
         s.append('    "condition": %r,' % hook.condition)
+      # Flattened hooks need to be written relative to the root gclient dir
+      cwd = os.path.relpath(os.path.normpath(hook.effective_cwd))
       s.extend(
-          # Hooks run in the parent directory of their dep.
-          ['      "cwd": "%s",' % os.path.normpath(os.path.dirname(dep.name))] +
+          ['    "cwd": "%s",' % cwd] +
           ['      "action": ['] +
           ['          "%s",' % arg for arg in hook.action] +
           ['      ]', '    },', '']