pw_watch: Use Git to determine which paths to ignore

- Do not trigger builds for any files not within or ignored by a Git
  repo.
- Remove ignore_dirs from PigweedBuildWatcher. With .gitignore applied,
  that is no longer necessary.

Change-Id: I008d155c458a7478f155b23d340fc19730a61431
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/41180
Pigweed-Auto-Submit: Wyatt Hepler <hepler@google.com>
Commit-Queue: Wyatt Hepler <hepler@google.com>
Reviewed-by: Keir Mierle <keir@google.com>
diff --git a/pw_watch/docs.rst b/pw_watch/docs.rst
index 17b55e1..6e0824a 100644
--- a/pw_watch/docs.rst
+++ b/pw_watch/docs.rst
@@ -40,12 +40,15 @@
   # Find a directory and build python.tests, and build pw_apps in out/cmake
   pw watch python.tests -C out/cmake pw_apps
 
-The ``--patterns`` and ``--ignore_patterns`` arguments can be used to include
-and exclude certain file patterns that will trigger rebuilds.
+``pw watch`` only rebuilds when a file that is not ignored by Git changes.
+Adding exclusions to a ``.gitignore`` causes watch to ignore them, even if the
+files were forcibly added to a repo. By default, only files matching certain
+extensions are applied, even if they're tracked by Git. The ``--patterns`` and
+``--ignore_patterns`` arguments can be used to include or exclude specific
+patterns. These patterns do not override Git's ignoring logic.
 
-The ``--exclude_list`` argument can be used to exclude directories from
-being watched by your system. This can decrease the inotify number in Linux
-system.
+The ``--exclude_list`` argument can be used to exclude directories from being
+watched. This decreases the number of files monitored with inotify in Linux.
 
 While running ``pw watch``, press enter to immediately trigger a rebuild.
 
diff --git a/pw_watch/py/pw_watch/watch.py b/pw_watch/py/pw_watch/watch.py
index 5674e66..30d221f 100755
--- a/pw_watch/py/pw_watch/watch.py
+++ b/pw_watch/py/pw_watch/watch.py
@@ -47,8 +47,8 @@
 from typing import (Iterable, List, NamedTuple, NoReturn, Optional, Sequence,
                     Tuple)
 
-from watchdog.events import FileSystemEventHandler  # type: ignore
-from watchdog.observers import Observer  # type: ignore
+from watchdog.events import FileSystemEventHandler  # type: ignore[import]
+from watchdog.observers import Observer  # type: ignore[import]
 
 import pw_cli.branding
 import pw_cli.color
@@ -120,6 +120,19 @@
         return ' '.join(shlex.quote(arg) for arg in self.args())
 
 
+def git_ignored(file: Path) -> bool:
+    """Returns true if this file is in a Git repo and ignored by that repo.
+
+    Returns true for ignored files that were manually added to a repo.
+    """
+    returncode = subprocess.run(
+        ['git', 'check-ignore', '--quiet', '--no-index', file],
+        stdout=subprocess.DEVNULL,
+        stderr=subprocess.DEVNULL,
+        cwd=file.parent).returncode
+    return returncode in (0, 128)
+
+
 class PigweedBuildWatcher(FileSystemEventHandler, DebouncedFunction):
     """Process filesystem events and launch builds if necessary."""
     def __init__(
@@ -127,7 +140,6 @@
         patterns: Sequence[str] = (),
         ignore_patterns: Sequence[str] = (),
         build_commands: Sequence[BuildCommand] = (),
-        ignore_dirs: Iterable[Path] = (),
         charset: WatchCharset = _ASCII_CHARSET,
         restart: bool = False,
     ):
@@ -136,8 +148,6 @@
         self.patterns = patterns
         self.ignore_patterns = ignore_patterns
         self.build_commands = build_commands
-        self.ignore_dirs = list(ignore_dirs)
-        self.ignore_dirs.extend(cmd.build_dir for cmd in self.build_commands)
         self.charset: WatchCharset = charset
 
         self.restart_on_changes = restart
@@ -147,7 +157,7 @@
 
         # Track state of a build. These need to be members instead of locals
         # due to the split between dispatch(), run(), and on_complete().
-        self.matching_path: Optional[str] = None
+        self.matching_path: Optional[Path] = None
         self.builds_succeeded: List[bool] = []
 
         self.wait_for_keypress_thread = threading.Thread(
@@ -166,28 +176,10 @@
         except (KeyboardInterrupt, EOFError):
             _exit_due_to_interrupt()
 
-    def _path_matches(self, raw_path: str) -> bool:
+    def _path_matches(self, path: Path) -> bool:
         """Returns true if path matches according to the watcher patterns"""
-        modified_path = Path(raw_path).resolve()
-
-        # Check for modifications inside the ignore directories, and skip them.
-        # Ideally these events would never hit the watcher, but selectively
-        # watching directories at the OS level is not trivial due to limitations
-        # of the watchdog module.
-        for ignore_dir in self.ignore_dirs:
-            resolved_ignore_dir = ignore_dir.resolve()
-            try:
-                modified_path.relative_to(resolved_ignore_dir)
-                # If no ValueError is raised by the .relative_to() call, then
-                # this file is inside the ignore directory; so skip it.
-                return False
-            except ValueError:
-                # Otherwise, the file isn't in the ignore directory, so run the
-                # normal pattern checks below.
-                pass
-
-        return ((not any(modified_path.match(x) for x in self.ignore_patterns))
-                and any(modified_path.match(x) for x in self.patterns))
+        return (not any(path.match(x) for x in self.ignore_patterns)
+                and any(path.match(x) for x in self.patterns))
 
     def dispatch(self, event) -> None:
         # There isn't any point in triggering builds on new directory creation.
@@ -202,16 +194,16 @@
             paths.append(os.fsdecode(event.dest_path))
         if event.src_path:
             paths.append(os.fsdecode(event.src_path))
-        for path in paths:
-            _LOG.debug('File event: %s', path)
+        for raw_path in paths:
+            _LOG.debug('File event: %s', raw_path)
 
-        # Check for matching paths among the one or two in the event.
-        for path in paths:
-            if self._path_matches(path):
+        # Check whether Git cares about any of these paths.
+        for path in (Path(p).resolve() for p in paths):
+            if not git_ignored(path) and self._path_matches(path):
                 self._handle_matched_event(path)
                 return
 
-    def _handle_matched_event(self, matching_path: str) -> None:
+    def _handle_matched_event(self, matching_path: Path) -> None:
         if self.matching_path is None:
             self.matching_path = matching_path
 
@@ -356,7 +348,7 @@
     parser.add_argument('--exclude_list',
                         nargs='+',
                         type=Path,
-                        help=('directories to ignore during pw watch'),
+                        help='directories to ignore during pw watch',
                         default=[])
     parser.add_argument('--restart',
                         action='store_true',
@@ -470,21 +462,6 @@
                 yield item, True
 
 
-def gitignore_patterns():
-    """Load patterns in pw_root_dir/.gitignore and return as [str]"""
-    pw_root_dir = Path(os.environ['PW_ROOT'])
-
-    # Get top level .gitignore entries
-    gitignore_path = pw_root_dir / Path('.gitignore')
-    if gitignore_path.exists():
-        for line in gitignore_path.read_text().splitlines():
-            globname = line.strip()
-            # If line is empty or a comment.
-            if not globname or globname.startswith('#'):
-                continue
-            yield line
-
-
 def get_common_excludes() -> List[Path]:
     """Find commonly excluded directories, and return them as a [Path]"""
     exclude_list: List[Path] = []
@@ -599,8 +576,6 @@
     # Ignore the user-specified patterns.
     ignore_patterns = (ignore_patterns_string.split(_WATCH_PATTERN_DELIMITER)
                        if ignore_patterns_string else [])
-    # Ignore top level pw_root_dir/.gitignore patterns.
-    ignore_patterns += gitignore_patterns()
 
     env = pw_cli.env.pigweed_environment()
     if env.PW_EMOJI:
@@ -612,8 +587,6 @@
         patterns=patterns.split(_WATCH_PATTERN_DELIMITER),
         ignore_patterns=ignore_patterns,
         build_commands=build_commands,
-        ignore_dirs=[Path('.presubmit'),
-                     Path('.python3-env')],
         charset=charset,
         restart=restart,
     )
@@ -658,7 +631,7 @@
     observer.join()
 
 
-def main():
+def main() -> None:
     """Watch files for changes and rebuild."""
     parser = argparse.ArgumentParser(
         description=__doc__,