StatCache: Provide timestamp cache abstraction

This introduces a new class named StatCache to abstract
timestamp cache implementations from DiskInterface itself.

For now, this only moves the Win32-specific implementation
to stat_cache-win32.cc, and provides a non-caching Posix
backend. This does not modifies Ninja's behavior in any
way.

In particular, this does not improve the performance
of the Win32 StatCache instance, which is only a
"timestamp preloader", and is not capable of detecting
changes between incremental builds.

+ Introduces the DiskInterface::Sync() method which can
  be used to ensure that changes to the filesystem that
  happened outside of DiskInterface() calls are properly
  accounted for.

+ Change StatFile() to GetFileTimestamp() in util.h
  to avoid confusion with the StatFile() method of the
  StatCache class.

A future CL will add an inotify-based cache to speed
up incremental builds in persistent mode on Linux.

Fuchsia-Topic: persistent-mode
Original-Change-Id: I0f68e8165b8f269decea5d4ee52aa2f25349e44d
Change-Id: Ib3b7748f882d5affe7f9a209f8de8619d22975ed
Reviewed-on: https://fuchsia-review.googlesource.com/c/third_party/github.com/ninja-build/ninja/+/1071431
Reviewed-by: Tyler Mandry <tmandry@google.com>
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 82980a9..c148ce1 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -202,6 +202,16 @@
 	endif()
 endif()
 
+if(WIN32)
+	target_sources(libninja PRIVATE
+		src/stat_cache-win32.cc
+	)
+else()
+	target_sources(libninja PRIVATE
+		src/stat_cache-posix.cc
+	)
+endif()
+
 target_compile_features(libninja PUBLIC cxx_std_11)
 
 #Fixes GetActiveProcessorCount on MinGW
@@ -299,6 +309,7 @@
     src/persistent_mode_test.cc
     src/persistent_service_test.cc
     src/process_utils_test.cc
+    src/stat_cache_test.cc
     src/state_test.cc
     src/status_table_test.cc
     src/stdio_redirection_test.cc
diff --git a/configure.py b/configure.py
index 4ecb0e3..2fc87c0 100755
--- a/configure.py
+++ b/configure.py
@@ -568,12 +568,12 @@
              'version']:
     objs += cxx(name, variables=cxxvariables)
 if platform.is_windows():
-    for name in ['subprocess-win32',
-                 'includes_normalize-win32',
+    for name in ['includes_normalize-win32',
                  'interrupt_handling-win32',
                  'ipc_handle-win32',
                  'msvc_helper-win32',
-                 'msvc_helper_main-win32']:
+                 'msvc_helper_main-win32',
+                 'subprocess-win32']:
         objs += cxx(name, variables=cxxvariables)
     if platform.is_msvc():
         objs += cxx('minidump-win32', variables=cxxvariables)
@@ -583,6 +583,12 @@
                  'ipc_handle-posix',
                  'subprocess-posix']:
         objs += cxx(name, variables=cxxvariables)
+
+if platform.is_windows():
+    objs += cxx('stat_cache-win32', variables=cxxvariables)
+else:
+    objs += cxx('stat_cache-posix', variables=cxxvariables)
+
 if platform.is_aix():
     objs += cc('getopt')
 if platform.is_msvc():
diff --git a/src/build.cc b/src/build.cc
index 5e38802..a62e352 100644
--- a/src/build.cc
+++ b/src/build.cc
@@ -595,8 +595,10 @@
 }
 
 struct RealCommandRunner : public CommandRunner {
-  explicit RealCommandRunner(const BuildConfig& config)
-      : config_(config), subprocs_(config_.environment) {}
+  explicit RealCommandRunner(const BuildConfig& config,
+                             DiskInterface& disk_interface)
+      : config_(config), subprocs_(config_.environment),
+        disk_interface_(disk_interface) {}
   virtual ~RealCommandRunner() {}
   size_t CanRunMore() const override;
   bool StartCommand(Edge* edge) override;
@@ -605,6 +607,7 @@
   void Abort() override;
 
   const BuildConfig& config_;
+  DiskInterface& disk_interface_;
   SubprocessSet subprocs_;
   map<const Subprocess*, Edge*> subproc_to_edge_;
 };
@@ -656,7 +659,12 @@
 bool RealCommandRunner::WaitForCommand(Result* result) {
   std::unique_ptr<Subprocess> subproc;
   while (!(subproc = subprocs_.NextFinished())) {
-    if (subprocs_.DoWork()) {
+    bool interrupted = subprocs_.DoWork();
+
+    // Launched sub-commands may have modified file timestamps.
+    disk_interface_.Sync();
+
+    if (interrupted) {
       result->status = ExitInterrupted;
       return false;
     }
@@ -721,9 +729,11 @@
     }
   }
 
+  // Remove lock file now.
   string err;
-  if (disk_interface_->Stat(lock_file_path_, &err) > 0)
+  if (disk_interface_->Stat(lock_file_path_, &err) > 0) {
     disk_interface_->RemoveFile(lock_file_path_);
+  }
 }
 
 Node* Builder::AddTarget(const string& name, string* err) {
@@ -780,7 +790,7 @@
     if (config_.dry_run)
       command_runner_.reset(new DryRunCommandRunner);
     else
-      command_runner_.reset(new RealCommandRunner(config_));
+      command_runner_.reset(new RealCommandRunner(config_, *disk_interface_));
   }
 
   // We are about to start the build process.
diff --git a/src/disk_interface.cc b/src/disk_interface.cc
index 7c325de..e8e0dd7 100644
--- a/src/disk_interface.cc
+++ b/src/disk_interface.cc
@@ -63,54 +63,6 @@
 #endif
 }
 
-#ifdef _WIN32
-bool IsWindows7OrLater() {
-  OSVERSIONINFOEX version_info =
-      { sizeof(OSVERSIONINFOEX), 6, 1, 0, 0, {0}, 0, 0, 0, 0, 0};
-  DWORDLONG comparison = 0;
-  VER_SET_CONDITION(comparison, VER_MAJORVERSION, VER_GREATER_EQUAL);
-  VER_SET_CONDITION(comparison, VER_MINORVERSION, VER_GREATER_EQUAL);
-  return VerifyVersionInfo(
-      &version_info, VER_MAJORVERSION | VER_MINORVERSION, comparison);
-}
-
-bool StatAllFilesInDir(const string& dir, map<string, TimeStamp>* stamps,
-                       string* err) {
-  // FindExInfoBasic is 30% faster than FindExInfoStandard.
-  static bool can_use_basic_info = IsWindows7OrLater();
-  // This is not in earlier SDKs.
-  const FINDEX_INFO_LEVELS kFindExInfoBasic =
-      static_cast<FINDEX_INFO_LEVELS>(1);
-  FINDEX_INFO_LEVELS level =
-      can_use_basic_info ? kFindExInfoBasic : FindExInfoStandard;
-  WIN32_FIND_DATAA ffd;
-  HANDLE find_handle = FindFirstFileExA((dir + "\\*").c_str(), level, &ffd,
-                                        FindExSearchNameMatch, NULL, 0);
-
-  if (find_handle == INVALID_HANDLE_VALUE) {
-    DWORD win_err = GetLastError();
-    if (win_err == ERROR_FILE_NOT_FOUND || win_err == ERROR_PATH_NOT_FOUND ||
-        win_err == ERROR_DIRECTORY)
-      return true;
-    *err = "FindFirstFileExA(" + dir + "): " + GetLastErrorString();
-    return false;
-  }
-  do {
-    string lowername = ffd.cFileName;
-    if (lowername == "..") {
-      // Seems to just copy the timestamp for ".." from ".", which is wrong.
-      // This is the case at least on NTFS under Windows 7.
-      continue;
-    }
-    transform(lowername.begin(), lowername.end(), lowername.begin(), ::tolower);
-    stamps->insert(make_pair(lowername,
-                             TimeStampFromFileTime(ffd.ftLastWriteTime)));
-  } while (FindNextFileA(find_handle, &ffd));
-  FindClose(find_handle);
-  return true;
-}
-#endif  // _WIN32
-
 }  // namespace
 
 // DiskInterface ---------------------------------------------------------------
@@ -138,7 +90,7 @@
 // RealDiskInterface -----------------------------------------------------------
 RealDiskInterface::RealDiskInterface()
 #ifdef _WIN32
-    : use_cache_(false), long_paths_enabled_(AreWin32LongPathsEnabled()) {
+    : long_paths_enabled_(AreWin32LongPathsEnabled()) {
 }
 #else
 {}
@@ -146,48 +98,7 @@
 
 TimeStamp RealDiskInterface::Stat(const string& path, string* err) const {
   METRIC_RECORD("node stat");
-#ifdef _WIN32
-  // MSDN: "Naming Files, Paths, and Namespaces"
-  // http://msdn.microsoft.com/en-us/library/windows/desktop/aa365247(v=vs.85).aspx
-  if (!path.empty() && !AreLongPathsEnabled() && path[0] != '\\' &&
-      path.size() > MAX_PATH) {
-    ostringstream err_stream;
-    err_stream << "Stat(" << path << "): Filename longer than " << MAX_PATH
-               << " characters";
-    *err = err_stream.str();
-    return -1;
-  }
-  if (!use_cache_)
-    return GetFileTimestamp(path, err);
-
-  string dir = DirName(path);
-  string base(path.substr(dir.size() ? dir.size() + 1 : 0));
-  if (base == "..") {
-    // StatAllFilesInDir does not report any information for base = "..".
-    base = ".";
-    dir = path;
-  }
-
-  string dir_lowercase = dir;
-  transform(dir.begin(), dir.end(), dir_lowercase.begin(), ::tolower);
-  transform(base.begin(), base.end(), base.begin(), ::tolower);
-
-  Cache::iterator ci = cache_.find(dir_lowercase);
-  if (ci == cache_.end()) {
-    ci = cache_.insert(make_pair(dir_lowercase, DirCache())).first;
-    if (!StatAllFilesInDir(dir.empty() ? "." : dir, &ci->second, err)) {
-      cache_.erase(ci);
-      return -1;
-    }
-  }
-  DirCache::iterator di = ci->second.find(base);
-  return di != ci->second.end() ? di->second : 0;
-#else   // !_WIN32
-  int64_t ret = GetFileTimestamp(path, err);
-  if (ret < 0)
-    return -1;
-  return ret;
-#endif  // !_WIN32
+  return stat_cache_.Stat(path, err);
 }
 
 bool RealDiskInterface::WriteFile(const string& path, const string& contents) {
@@ -198,6 +109,7 @@
     return false;
   }
 
+  stat_cache_.Invalidate(path);
   if (fwrite(contents.data(), 1, contents.length(), fp) < contents.length())  {
     Error("WriteFile(%s): Unable to write to the file. %s",
           path.c_str(), strerror(errno));
@@ -215,11 +127,15 @@
 }
 
 bool RealDiskInterface::MakeDir(const string& path) {
-  if (::MakeDir(path) < 0) {
-    if (errno == EEXIST) {
-      return true;
-    }
-    Error("mkdir(%s): %s", path.c_str(), strerror(errno));
+  int ret = ::MakeDir(path);
+  if (ret < 0 && errno == EEXIST)
+    return true;
+
+  int saved_errno = errno;
+  // Invalidate just in case the call modified something on the disk.
+  stat_cache_.Invalidate(path);
+  if (ret < 0) {
+    Error("mkdir(%s): %s", path.c_str(), strerror(saved_errno));
     return false;
   }
   return true;
@@ -236,6 +152,7 @@
 }
 
 int RealDiskInterface::RemoveFile(const string& path) {
+  stat_cache_.Invalidate(path);
 #ifdef _WIN32
   DWORD attributes = GetFileAttributesA(path.c_str());
   if (attributes == INVALID_FILE_ATTRIBUTES) {
@@ -291,11 +208,11 @@
 }
 
 void RealDiskInterface::AllowStatCache(bool allow) {
-#ifdef _WIN32
-  use_cache_ = allow;
-  if (!use_cache_)
-    cache_.clear();
-#endif
+  stat_cache_.Enable(allow);
+}
+
+void RealDiskInterface::Sync() {
+  stat_cache_.Sync();
 }
 
 #ifdef _WIN32
@@ -305,7 +222,5 @@
 #endif
 
 void RealDiskInterface::FlushCache() {
-#ifdef _WIN32
-  cache_.clear();
-#endif
+  stat_cache_.Flush();
 }
diff --git a/src/disk_interface.h b/src/disk_interface.h
index 3e814f8..8fcb2a1 100644
--- a/src/disk_interface.h
+++ b/src/disk_interface.h
@@ -19,6 +19,7 @@
 #include <string>
 #include <vector>
 
+#include "stat_cache.h"
 #include "timestamp.h"
 
 /// Interface for reading files from disk.  See DiskInterface for details.
@@ -66,6 +67,13 @@
   /// Create all the parent directories for path; like mkdir -p
   /// `basename path`.
   bool MakeDirs(const std::string& path);
+
+  /// Sync with real filesystem state, which is useful when a timestamp cache
+  /// is implemented using a directory-watching system service. Calling this
+  /// is only needed when calling Stat() on files that may have been modified
+  /// through a different interface (e.g. files modified by command
+  /// sub-processes).
+  virtual void Sync() {}
 };
 
 /// Implementation of FileReader that tracks opened file paths and their
@@ -115,13 +123,13 @@
 /// Implementation of DiskInterface that actually hits the disk.
 struct RealDiskInterface : public DiskInterface {
   RealDiskInterface();
-  virtual ~RealDiskInterface() {}
-  virtual TimeStamp Stat(const std::string& path, std::string* err) const;
-  virtual bool MakeDir(const std::string& path);
-  virtual bool WriteFile(const std::string& path, const std::string& contents);
-  virtual Status ReadFile(const std::string& path, std::string* contents,
-                          std::string* err);
-  virtual int RemoveFile(const std::string& path);
+  virtual ~RealDiskInterface() = default;
+  TimeStamp Stat(const std::string& path, std::string* err) const override;
+  bool MakeDir(const std::string& path) override;
+  bool WriteFile(const std::string& path, const std::string& contents) override;
+  Status ReadFile(const std::string& path, std::string* contents,
+                  std::string* err) override;
+  int RemoveFile(const std::string& path) override;
 
   /// Whether stat information can be cached.  Only has an effect on Windows.
   void AllowStatCache(bool allow);
@@ -131,26 +139,20 @@
   bool AreLongPathsEnabled() const;
 #endif
 
-  /// Ensure stat cache is synchronized with the filesystem.
-  void SyncCache() { AllowStatCache(false); }
+  /// If StatCache is allowed, synchronize it with the current filesystem
+  /// state. This is used to drain pending events from filesystem monitoring
+  /// services like inotify on Linux.
+  void Sync() override;
 
   /// Remove all cached stat information, if any.
   void FlushCache();
 
  private:
 #ifdef _WIN32
-  /// Whether stat information can be cached.
-  bool use_cache_;
-
   /// Whether long paths are enabled.
   bool long_paths_enabled_;
-
-  typedef std::map<std::string, TimeStamp> DirCache;
-  // TODO: Neither a map nor a hashmap seems ideal here.  If the statcache
-  // works out, come up with a better data structure.
-  typedef std::map<std::string, DirCache> Cache;
-  mutable Cache cache_;
-#endif
+#endif  // _WIN32
+  StatCache stat_cache_;
 };
 
 #endif  // NINJA_DISK_INTERFACE_H_
diff --git a/src/ninja.cc b/src/ninja.cc
index 8debbd6..9a5a3c7 100644
--- a/src/ninja.cc
+++ b/src/ninja.cc
@@ -140,6 +140,7 @@
     // Reset status, since stdio was redirected when in persistent
     // server process.
     status_.reset(Status::factory(config));
+    disk_interface_.Sync();
   }
 
   /// Loaded state (rules, nodes).
@@ -1515,7 +1516,6 @@
   }
 
   disk_interface_.AllowStatCache(g_experimental_statcache);
-  disk_interface_.SyncCache();
 
   Builder builder(&state_, *config_, &build_log_, &deps_log_, &disk_interface_,
                   status(), start_time_millis_);
diff --git a/src/stat_cache-posix.cc b/src/stat_cache-posix.cc
new file mode 100644
index 0000000..2855df3
--- /dev/null
+++ b/src/stat_cache-posix.cc
@@ -0,0 +1,51 @@
+// Copyright 2023 Google Inc. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include <errno.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+
+#include "stat_cache.h"
+#include "util.h"
+
+// This implementation calls stat() directly and never caches anything.
+class StatCache::Impl {
+ public:
+  void Enable(bool) {}
+
+  TimeStamp Stat(const std::string& path, std::string* err) const {
+    return ::GetFileTimestamp(path, err);
+  }
+};
+
+StatCache::StatCache() : impl_(new StatCache::Impl()) {}
+
+StatCache::~StatCache() = default;
+
+void StatCache::Enable(bool enabled) {
+  impl_->Enable(enabled);
+}
+
+void StatCache::Invalidate(const std::string&) {
+  // Nothing to do here.
+}
+
+TimeStamp StatCache::Stat(const std::string& path, std::string* err) const {
+  return impl_->Stat(path, err);
+}
+
+void StatCache::Sync() {}
+
+void StatCache::Flush() {}
diff --git a/src/stat_cache-win32.cc b/src/stat_cache-win32.cc
new file mode 100644
index 0000000..9b7339e
--- /dev/null
+++ b/src/stat_cache-win32.cc
@@ -0,0 +1,199 @@
+// Copyright 2023 Google Inc. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include <windows.h>
+
+#include <algorithm>
+#include <map>
+
+#include "stat_cache.h"
+#include "util.h"
+
+namespace {
+
+bool IsPathSeparator(char ch) {
+  return (ch == '/' || ch == '\\');
+}
+
+// Compute the dirname and basename of a given path.
+// If there is no directory separator, set |*dir| to ".".
+void DecomposePath(const std::string& path, std::string* dir,
+                   std::string* base) {
+  size_t pos = path.size();
+
+  // Find first directory separator before base name.
+  while (pos > 0 && !IsPathSeparator(path[pos - 1]))
+    --pos;
+
+  *base = path.substr(pos);
+
+  // Skip over separator(s) to find directory name, might be empty
+  while (pos > 1 && IsPathSeparator(path[pos - 2]))
+    --pos;
+
+  *dir = path.substr(0, pos);
+}
+
+bool IsWindows7OrLater() {
+  OSVERSIONINFOEX version_info = {
+    sizeof(OSVERSIONINFOEX), 6, 1, 0, 0, { 0 }, 0, 0, 0, 0, 0
+  };
+  DWORDLONG comparison = 0;
+  VER_SET_CONDITION(comparison, VER_MAJORVERSION, VER_GREATER_EQUAL);
+  VER_SET_CONDITION(comparison, VER_MINORVERSION, VER_GREATER_EQUAL);
+  return VerifyVersionInfo(&version_info, VER_MAJORVERSION | VER_MINORVERSION,
+                           comparison);
+}
+
+using DirCache = std::map<std::string, TimeStamp>;
+
+// TODO: Neither a map nor a hashmap seems ideal here.  If the statcache
+// works out, come up with a better data structure.
+using Cache = std::map<std::string, DirCache>;
+
+bool StatAllFilesInDir(const std::string& dir, DirCache* stamps,
+                       std::string* err) {
+  // FindExInfoBasic is 30% faster than FindExInfoStandard.
+  static bool can_use_basic_info = IsWindows7OrLater();
+  // This is not in earlier SDKs.
+  const FINDEX_INFO_LEVELS kFindExInfoBasic =
+      static_cast<FINDEX_INFO_LEVELS>(1);
+  FINDEX_INFO_LEVELS level =
+      can_use_basic_info ? kFindExInfoBasic : FindExInfoStandard;
+  WIN32_FIND_DATAA ffd;
+  HANDLE find_handle = FindFirstFileExA((dir + "\\*").c_str(), level, &ffd,
+                                        FindExSearchNameMatch, NULL, 0);
+
+  if (find_handle == INVALID_HANDLE_VALUE) {
+    DWORD win_err = GetLastError();
+    if (win_err == ERROR_FILE_NOT_FOUND || win_err == ERROR_PATH_NOT_FOUND ||
+        win_err == ERROR_DIRECTORY)
+      return true;
+    *err = StringFormat("FindFirstFileExA(%s): %s", dir.c_str(),
+                        GetLastErrorString().c_str());
+    return false;
+  }
+  do {
+    std::string lowername = ffd.cFileName;
+    if (lowername == "..") {
+      // Seems to just copy the timestamp for ".." from ".", which is wrong.
+      // This is the case at least on NTFS under Windows 7.
+      continue;
+    }
+    std::transform(lowername.begin(), lowername.end(), lowername.begin(),
+                   ::tolower);
+    // C++11 equivalent to
+    // stamps->try_emplace(std::move(lowername), TimeStampFromFile(...))
+    stamps->emplace(
+        std::piecewise_construct, std::forward_as_tuple(std::move(lowername)),
+        std::forward_as_tuple(TimeStampFromFileTime(ffd.ftLastWriteTime)));
+  } while (FindNextFileA(find_handle, &ffd));
+  FindClose(find_handle);
+  return true;
+}
+
+}  // namespace
+
+class StatCache::Impl {
+ public:
+  void Enable(bool enabled) {
+    if (!enabled)
+      cache_.clear();
+
+    enabled_ = enabled;
+  }
+
+  TimeStamp Stat(const std::string& path, std::string* err) const {
+    // MSDN: "Naming Files, Paths, and Namespaces"
+    // http://msdn.microsoft.com/en-us/library/windows/desktop/aa365247(v=vs.85).aspx
+    if (!path.empty() && !long_path_enabled_ && path[0] != '\\' &&
+        path.size() > MAX_PATH) {
+      *err = StringFormat("Stat(%s): Filename longer than %u characters",
+                          path.c_str(), MAX_PATH);
+      return -1;
+    }
+    if (!enabled_)
+      return ::GetFileTimestamp(path, err);
+
+    std::string dir, base;
+    DecomposePath(path, &dir, &base);
+    if (base == "..") {
+      // StatAllFilesInDir does not report any information for base = "..".
+      base = ".";
+      dir = path;
+    }
+
+    std::string dir_lowercase = dir;
+    std::transform(dir.begin(), dir.end(), dir_lowercase.begin(), ::tolower);
+    std::transform(base.begin(), base.end(), base.begin(), ::tolower);
+
+    // NOTE: The following is the C++11 equivalent of
+    // cache_.try_emplace(std::move(dir_lowercase))
+    auto ret = cache_.emplace(std::piecewise_construct,
+                              std::forward_as_tuple(std::move(dir_lowercase)),
+                              std::forward_as_tuple());
+    Cache::iterator ci = ret.first;
+    if (ret.second) {
+      if (!StatAllFilesInDir(dir.empty() ? "." : dir, &ci->second, err)) {
+        cache_.erase(ci);
+        return -1;
+      }
+    }
+    DirCache::iterator di = ci->second.find(base);
+    return di != ci->second.end() ? di->second : 0;
+  }
+
+  void Sync() {
+    // TODO(digit): Implement this properly!
+    // FOR NOW, drop everything from the cache to pass the unit-tests!
+    cache_.clear();
+  }
+
+  void Flush() { cache_.clear(); }
+
+ private:
+  /// Whether stat information can be cached.
+  bool enabled_ = false;
+
+  /// Wether long paths are enabled on this machine.
+  bool long_path_enabled_ = AreWin32LongPathsEnabled();
+
+  /// The cache itself.
+  mutable Cache cache_;
+};
+
+StatCache::StatCache() : impl_(new StatCache::Impl()) {}
+
+StatCache::~StatCache() = default;
+
+void StatCache::Enable(bool enabled) {
+  impl_->Enable(enabled);
+}
+
+TimeStamp StatCache::Stat(const std::string& path, std::string* err) const {
+  return impl_->Stat(path, err);
+}
+
+void StatCache::Invalidate(const std::string& path) {
+  // TODO(digit): Only remove entries from the path's parent directory.
+  impl_->Sync();
+}
+
+void StatCache::Sync() {
+  impl_->Sync();
+}
+
+void StatCache::Flush() {
+  impl_->Flush();
+}
diff --git a/src/stat_cache.h b/src/stat_cache.h
new file mode 100644
index 0000000..7d6322d
--- /dev/null
+++ b/src/stat_cache.h
@@ -0,0 +1,56 @@
+// Copyright 2023 Google Inc. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef NINJA_STAT_CACHE_H_
+#define NINJA_STAT_CACHE_H_
+
+#include <memory>
+#include <string>
+
+#include "timestamp.h"
+
+/// Implement a cache for file path timestamps.
+class StatCache {
+ public:
+  /// Constructor
+  StatCache();
+
+  /// Destructor
+  ~StatCache();
+
+  /// Enable caching of timestamps. If false (the default), the Stat() performs
+  /// a single stat() operation per file and no caching is performed.
+  void Enable(bool enabled);
+
+  /// Mark a path as invalid.
+  void Invalidate(const std::string& path);
+
+  /// Return the timestamp of a given file path. On error, set |*err| then
+  /// return -1. Otherwise, return 0 if the file is missing, or a strictly
+  /// positive value if it exists.
+  TimeStamp Stat(const std::string& path, std::string* err) const;
+
+  /// Synchronize the cache state with recent filesystem events.
+  /// Call this before one or more Stat() calls.
+  void Sync();
+
+  /// Remove all entries from the cache.
+  void Flush();
+
+ private:
+  class Impl;
+  std::unique_ptr<Impl> impl_;
+};
+
+#endif  // NINJA_STAT_CACHE_H_
diff --git a/src/stat_cache_test.cc b/src/stat_cache_test.cc
new file mode 100644
index 0000000..f6b8455
--- /dev/null
+++ b/src/stat_cache_test.cc
@@ -0,0 +1,128 @@
+// Copyright 2023 Google Inc. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "stat_cache.h"
+
+#include <stdio.h>
+
+#include "test.h"
+#include "util.h"
+
+namespace {
+
+void WriteFile(const std::string& path, const std::string& content) {
+  FILE* f = fopen(path.c_str(), "wb");
+  if (!f)
+    ErrnoFatal("fopen", path.c_str());
+
+  size_t ret = fwrite(content.c_str(), content.size(), 1, f);
+  if (ret != 1)
+    ErrnoFatal("Could not write to %s", path.c_str());
+  fclose(f);
+}
+
+void RemoveFile(const std::string& path) {
+  ::remove(path.c_str());
+}
+
+}  // namespace
+
+TEST(StatCache, CheckMissingTimestamp) {
+  ScopedTempDir temp_dir;
+  temp_dir.CreateAndEnter("StatCacheTest");
+  auto top_dir = GetCurrentDir();
+
+  StatCache cache;
+  cache.Enable(true);
+
+  std::string err;
+  TimeStamp t = cache.Stat(top_dir + "/foo", &err);
+  EXPECT_EQ(0, t);
+  EXPECT_TRUE(err.empty());
+
+  t = cache.Stat(top_dir + "/bar", &err);
+  EXPECT_EQ(0, t);
+  EXPECT_TRUE(err.empty());
+
+  // Try foo again, just in case.
+  t = cache.Stat(top_dir + "/foo", &err);
+  EXPECT_EQ(0, t);
+  EXPECT_TRUE(err.empty());
+}
+
+TEST(StatCache, CheckExistingTimestamp) {
+  ScopedTempDir temp_dir;
+  temp_dir.CreateAndEnter("StatCacheTest");
+  auto top_dir = GetCurrentDir();
+
+  StatCache cache;
+  cache.Enable(true);
+
+  std::string foo_path = top_dir + "/foo";
+
+  WriteFile(foo_path, "foo!!");
+
+  std::string err;
+  TimeStamp t = cache.Stat(foo_path, &err);
+  EXPECT_GE(t, 0);
+  EXPECT_TRUE(err.empty());
+}
+
+TEST(StatCache, CheckCreatedTimestamp) {
+  ScopedTempDir temp_dir;
+  temp_dir.CreateAndEnter("StatCacheTest");
+  auto top_dir = GetCurrentDir();
+
+  StatCache cache;
+  cache.Enable(true);
+
+  std::string foo_path = top_dir + "/foo";
+
+  std::string err;
+  TimeStamp t = cache.Stat(foo_path, &err);
+  EXPECT_EQ(0, t);
+  EXPECT_TRUE(err.empty());
+
+  WriteFile(foo_path, "foo!!");
+  cache.Sync();
+
+  TimeStamp t2 = cache.Stat(foo_path, &err);
+  EXPECT_NE(0, t2);
+  EXPECT_TRUE(err.empty());
+}
+
+TEST(StatCache, CheckDeletedTimestamp) {
+  ScopedTempDir temp_dir;
+  temp_dir.CreateAndEnter("StatCacheTest");
+  auto top_dir = GetCurrentDir();
+
+  StatCache cache;
+  cache.Enable(true);
+
+  std::string foo_path = top_dir + "/foo";
+  WriteFile(foo_path, "foo!!");
+
+  std::string err;
+  TimeStamp t = cache.Stat(foo_path, &err);
+  EXPECT_NE(0, t);
+  EXPECT_TRUE(err.empty());
+
+  RemoveFile(foo_path);
+
+  cache.Sync();
+
+  TimeStamp t2 = cache.Stat(foo_path, &err);
+  EXPECT_EQ(0, t2);
+  EXPECT_TRUE(err.empty());
+}