blob: b42884c7e863bcd96c250e67e963df939645627c [file] [log] [blame]
#!/usr/bin/env python3
# Copyright 2025 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""
A wrapper for autoninja that automatically recompiles when files change.
This script monitors the current directory and its subdirectories for file
changes by periodically checking file modification times. When a change is
detected, it automatically triggers a build using `autoninja` with the
arguments provided to this script.
The build output is logged to `ininja.logs` in the specified output
directory.
Two common scenarios:
1. Regular local development
Example usage:
```
./tools/ininja/ininja.py -C out/default chrome
```
2. Gemini CLI integration
When Gemini CLI makes changes, it will check the compile failures in ininja.logs
and try to fix compile failures. See the following instructions for how to
prompt Gemini CLI to do this.
Example usage:
* In terminal 1, run
```
./tools/ininja/ininja.py -C out/default chrome -quiet
```
You need `-quiet` to only output error logs to save Gemini tokens. Or else you
may hit quota error quickly. If you don't use `-quiet`, you need to change the
prompt below to check more lines for error logs.
* In terminal 2, run Gemini CLI. Then use this prompt:
"After you make changes, always check <$pwd>/out/linux/ininja.logs file to
verify your change. The file is a log file for auto re-compile system. After
you make changes, mark the unix timestamp as 'Change timestamp'. The logs
contain build finish line '--- Build finished at <unix build timestamp>' as
'Build timestamp'. If the 'Change timestamp' is newer than the
'Build timestamp', wait for another 10 seconds and try again. If you see
'Gemini CLI should stop waiting', then you should stop waiting. The compile
can take up to 5 minutes so be patient. Don't run any compile within Gemini
CLI. You should make sure the 'Change timestamp' is earlier than the
'Build timestamp'. If there is a compile failure, try to fix it and check
the logs again. Only fix the files with errors to save time. When getting
compile errors, only check the last 50 lines to save Gemini tokens. Also
only look for errors after the latest '--- Build started at'. All previous
logs are stale. When the user asks to 'fix compile failures', check the logs
again with the same rule. The compile failures might be caused by user
changes, not generated by Gemini."
* Then you can interact with Gemini CLI to make changes and Gemini CLI will
correct itself. When you make local changes out of Gemini CLI, you can also
ask Gemini CLI to 'fix compile failures'. And it should read `ininja.logs` and
fix it.
"""
import sys
import os
import subprocess
import time
# --- Configuration ---
# Directory to watch ('.' for current directory)
WATCH_PATH = '.'
# Directory to exclude from watching
EXCLUDE_DIR = os.path.join(WATCH_PATH, 'out')
# Time to wait between checking for file changes, in seconds
POLL_INTERVAL = 2
# Log file name
LOG_FILE_NAME = 'ininja.logs'
# --- End Configuration ---
def get_changed_file_states():
"""
Uses git to find changed/untracked files and returns their mtimes.
This is a hybrid approach that is both fast and accurate. A change
is detected if the list of files changes or if any of their
modification times change.
"""
try:
result = subprocess.run(
['git', 'status', '-z', '--porcelain', '--untracked-files=all'],
capture_output=True,
text=True,
encoding='utf-8',
check=True)
except (subprocess.CalledProcessError, FileNotFoundError) as e:
print(f"Error running 'git status': {e}")
print("Please ensure you are in a git repository.")
sys.exit(1)
file_states = {}
# Handle the case where there are no changes.
if not result.stdout.strip():
return file_states
# The output of `git status -z --porcelain`` gives lines like:
# 'R newfile\0oldfile\0 M path/to/modified/file.py\0
# ?? path/to/untracked/file.py'
# We need to reliably extract the path.
for line in result.stdout.split('\0'):
parts = line.split()
if len(parts) < 2:
continue
filepath = parts[1]
try:
file_states[filepath] = os.path.getmtime(filepath)
except OSError:
# File was likely deleted. We'll mark it with a special
# timestamp so the change is detected.
file_states[filepath] = -1
return file_states
def run_ninja(ninja_args, log_path):
"""
Executes the autoninja command with the provided arguments and logs output.
"""
print("-> Kicking off ninja build...")
command = ['autoninja'] + ninja_args
start_time = int(time.time())
start_message = f"--- Build started at {start_time} ---"
print(start_message)
return_code = -1
try:
# Open the log file in write mode 'w' to clear it for the new build.
with open(log_path, 'w', encoding='utf-8') as log_file:
log_file.write(start_message + '\n')
# Use Popen to stream output in real-time.
process = subprocess.Popen(command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding='utf-8')
for line in iter(process.stdout.readline, ''):
print(line, end='')
log_file.write(line)
return_code = process.wait()
except FileNotFoundError:
print("Error: 'autoninja' command not found.")
print("Is depot_tools in your PATH?")
sys.exit(1)
except Exception as e:
print(f"An error occurred while running autoninja: {e}")
print("Gemini CLI should stop waiting if you are using it.")
sys.exit(1)
finish_time = int(time.time())
finish_message = (f"--- Build finished at {finish_time} "
f"with exit code {return_code} ---")
print(finish_message)
# Re-open in append mode to add the finish message.
with open(log_path, 'a', encoding='utf-8') as log_file:
log_file.write(finish_message + '\n\n')
if return_code == 0:
print("\nāœ… Build successful.")
else:
print(f"\nāŒ Build failed with exit code {return_code}.")
def main():
"""Main function to set up the file watcher and run the initial build."""
# Use a basic approach to find the output directory
# to avoid adding too much complexity with argparse for this simple case.
output_dir = None
ninja_args = sys.argv[1:]
for i, arg in enumerate(ninja_args):
if arg == '-C' and i + 1 < len(ninja_args):
output_dir = ninja_args[i + 1]
break
elif arg.startswith('-C'):
output_dir = arg[2:]
break
if not ninja_args or output_dir is None:
print("Usage: ./tools/ininja/ininja.py -C <output_directory> "
"target1 target2 ...")
print("Example: ./tools/ininja/ininja.py -C out/default chrome")
return
log_path = os.path.join(output_dir, LOG_FILE_NAME)
print(f"šŸ“ Logging build output to {log_path}")
# Perform the initial build.
print("ā–¶ļø Starting initial build...")
run_ninja(ninja_args, log_path)
# Get the initial state of the files.
previous_states = get_changed_file_states()
print(f"šŸ‘€ Watching for file changes (polling every {POLL_INTERVAL}s)...")
# Start polling for changes.
try:
while True:
time.sleep(POLL_INTERVAL)
current_states = get_changed_file_states()
if current_states != previous_states:
print("\n-> Change detected.")
run_ninja(ninja_args, log_path)
print(
f"\nšŸ‘€ Watching for file changes (polling every {POLL_INTERVAL}s)..."
)
previous_states = current_states
except KeyboardInterrupt:
print("\nšŸ›‘ Watcher stopped. Exiting.")
if __name__ == '__main__':
main()