blob: de1774ae148625a0515e50e62478047f6519d623 [file] [edit]
# Copyright 2026 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Runs an arbitrary command and preserves modification times for unchanged outputs.
Compilers (such as `tsc`) often rewrite output files unconditionally, updating
their modification timestamps (`mtime`) even when their contents have not
changed. In Ninja, this triggers cascading rebuilds of downstream targets.
This script acts as a transparent wrapper:
1. It records SHA1 hashes and modification timestamps (`mtime`) of existing
tracked outputs before invoking the command.
2. It executes the specified compiler command.
3. If the command succeeds, it compares the SHA1 hash of each tracked output.
If the content has not changed, the original `mtime` is restored.
When paired with Ninja's `restat = 1` action rule, unchanged outputs prevent
unnecessary downstream rebuilds, significantly accelerating incremental builds.
Usage:
run_with_restat.py [<tracked_output>...] -- <command> [<command_args>...]
Any argument starting with '@' (e.g. '@path/to/response_file.rsp') is expanded
in-place, allowing all tracked outputs, the '--' separator, and compiler
arguments to be passed inside a single GN/Ninja response file:
run_with_restat.py @{{response_file_name}}
"""
import hashlib
import os
import shlex
import subprocess
import sys
def expand_response_files(args, visited=None):
"""Expands any @response_file arguments in-place recursively."""
if visited is None:
visited = set()
expanded = []
for arg in args:
if arg.startswith('@'):
rsp_path = os.path.abspath(arg[1:])
if rsp_path in visited:
raise ValueError(
f"Circular response file reference detected: {arg[1:]}")
visited.add(rsp_path)
with open(rsp_path, 'r', encoding='utf-8') as f:
nested_args = shlex.split(f.read())
expanded.extend(expand_response_files(nested_args, visited))
visited.remove(rsp_path)
else:
expanded.append(arg)
return expanded
def hash_file(path):
h = hashlib.sha1()
try:
with open(path, 'rb') as f:
while chunk := f.read(65536):
h.update(chunk)
return h.digest()
except OSError:
return None
def main():
args = expand_response_files(sys.argv[1:])
if '--' not in args:
print(
"Usage: run_with_restat.py [<tracked_output>...] -- <command> [args...]",
file=sys.stderr)
print(" (Arguments and @response_files are expanded in-place)",
file=sys.stderr)
sys.exit(1)
dash_index = args.index('--')
tracked_files = args[:dash_index]
command_args = args[dash_index + 1:]
if not command_args:
print("Error: No command specified after '--'", file=sys.stderr)
sys.exit(1)
# Record existing state of tracked output files (nanosecond precision)
before_state = {}
for path in tracked_files:
try:
st = os.stat(path)
digest = hash_file(path)
if digest is not None:
before_state[path] = (st.st_atime_ns, st.st_mtime_ns, digest)
except OSError:
pass
# Execute the command
ret = subprocess.call(command_args)
if ret != 0:
if ret < 0:
sys.exit(128 - ret)
sys.exit(ret)
# Restore exact nanosecond timestamps for tracked files whose contents are identical
for path, (old_atime_ns, old_mtime_ns, old_digest) in before_state.items():
if os.path.exists(path):
try:
new_digest = hash_file(path)
if new_digest == old_digest:
os.utime(path, ns=(old_atime_ns, old_mtime_ns))
except OSError:
pass
sys.exit(0)
if __name__ == '__main__':
main()