blob: 80696f125c6a88ac7f15cd3bff29b1a0c3d996f3 [file] [edit]
#!/usr/bin/env fuchsia-vendored-python
# Copyright 2026 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""A script used to compare the outputs of two different GN binaries.
This tool is useful to compare the full `gn gen` output of two
different GN binaries for all Fuchsia trybot configurations.
For maximum efficiency and correctness:
- Use this in a checkout that includes //vendor/google-smart
as well as //vendor/google (if comparing internal configs)
- Use this on a system that supports `cp --reflink=always`
such as btrfs.
It will do the following:
- Scan integration/infra/config/generated/{fuchsia,turquoise}/fint_params/try
to list all the fint params files used by Fuchsia CQ.
- Bootstrap fint from sources (to ensure it's using the one
corresponding to the current checkout).
- Find all fint params for configurations that match the current
checkout (e.g. if the configuration requires //vendor/google-smart
and it's not in the checkout, it will be skipped automatically).
- Create an out/gn_compare/ directory where all outputs will be
stored.
For each build configuration, then:
- Use `fint set` with the right parameters from its fint_params
file, to setup `out/compare_gn.run/args.gn`.
- Save the clean state of `out/compare_gn.run` to `out/compare_gn.template`.
- Run `gn1 gen out/compare_gn.run`, then
move the result to out/gn_compare/<config_name>.1.
- Restore the clean state from `out/compare_gn.template` to `out/compare_gn.run`.
- Run `gn2 gen out/compare_gn.run` then
move the result to out/gn_compare/<config_name>.2.
- Report which files are different between <config_name>.1
and <config_name>.2 to let the user inspect the differences
manually.
Use the --config-count=NUMBER to limit the number of configurations
to compare. This is useful during debugging.
Use the --config-filter=PATTERN to limit the comparison to configurations
matching a specific regex pattern (substring by default).
"""
import argparse
import filecmp
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
def find_fint_params(fuchsia_dir: Path) -> list[Path]:
"""Finds all fint_params files in both fuchsia and turquoise try directories."""
configs: list[Path] = []
for project in ["fuchsia", "turquoise"]:
params_dir = (
fuchsia_dir
/ f"integration/infra/config/generated/{project}/fint_params/try"
)
if params_dir.exists():
configs.extend(params_dir.glob("*.textproto"))
return configs
def check_config_valid(fuchsia_dir: Path, params_path: Path) -> bool:
"""Checks if the config references any non-existent vendor directories."""
with open(params_path, "r") as f:
content = f.read()
# Find all //vendor/... strings
vendor_paths = re.findall(r'"(//vendor/[^"]+)"', content)
for vp in vendor_paths:
# Remove // and split at :
rel_path = vp.removeprefix("//").split(":")[0]
abs_path = fuchsia_dir / rel_path
if not abs_path.exists():
return False
return True
def bootstrap_fint(fuchsia_dir: Path, tmp_dir: Path) -> Path:
"""Bootstraps fint tool."""
bootstrap_script = fuchsia_dir / "tools/integration/bootstrap.sh"
fint_path = tmp_dir / "fint"
print("Bootstrapping fint...")
subprocess.check_call(
[str(bootstrap_script), "-o", str(fint_path)], cwd=fuchsia_dir
)
return fint_path
def generate_context_proto(
tmp_dir: Path, fuchsia_dir: Path, build_dir: Path
) -> Path:
"""Generates a context.textproto file."""
context_path = tmp_dir / "context.textproto"
artifact_dir = build_dir / "artifacts"
artifact_dir.mkdir(parents=True, exist_ok=True)
content = f"""checkout_dir: "{fuchsia_dir}"
build_dir: "{build_dir}"
artifact_dir: "{artifact_dir}"
"""
context_path.write_text(content)
return context_path
def run_fint_set(
fint_path: Path, static_proto: Path, context_proto: Path, fuchsia_dir: Path
) -> bool:
"""Runs fint set to initialize the build directory."""
cmd = [
str(fint_path),
"set",
"-static",
str(static_proto),
"-context",
str(context_proto),
]
try:
subprocess.run(
cmd, check=True, capture_output=True, text=True, cwd=fuchsia_dir
)
return True
except subprocess.CalledProcessError as e:
print(f"fint set failed for {static_proto.name}:", file=sys.stderr)
print(e.stderr, file=sys.stderr)
return False
def copy_dir_reflink(src: Path, dst: Path) -> bool:
"""Copies a directory using btrfs reflink if possible. Cleans up dst on failure."""
try:
dst.parent.mkdir(parents=True, exist_ok=True)
subprocess.run(
["cp", "--reflink=always", "-rp", str(src), str(dst)],
check=True,
capture_output=True,
)
return True
except subprocess.CalledProcessError:
if dst.exists():
shutil.rmtree(dst)
return False
def move_dir(src: Path, dst: Path) -> bool:
"""Moves a directory, trying rename first, then reflink copy, then regular copy."""
try:
os.rename(src, dst)
return True
except OSError:
# Ensure destination is clean before fallback copy
if dst.exists():
shutil.rmtree(dst)
if not copy_dir_reflink(src, dst):
shutil.copytree(src, dst)
shutil.rmtree(src)
return False
def compare_directories(dir1: Path, dir2: Path) -> list[str]:
"""Recursively compares two directories, returning list of differences."""
diffs = []
files1 = set(p.relative_to(dir1) for p in dir1.rglob("*") if p.is_file())
files2 = set(p.relative_to(dir2) for p in dir2.rglob("*") if p.is_file())
only_in_1 = files1 - files2
only_in_2 = files2 - files1
for f in only_in_1:
diffs.append(f"Only in GN1: {f}")
for f in only_in_2:
diffs.append(f"Only in GN2: {f}")
common = files1 & files2
for f in common:
f1 = dir1 / f
f2 = dir2 / f
if f.name in [
"args.gn",
"set_artifacts.json",
"gn_trace.json",
"command-line.txt",
]:
continue
if "artifacts" in f.parts:
continue
if not filecmp.cmp(f1, f2, shallow=False):
diffs.append(f"Content differs: {f}")
return diffs
def main() -> int:
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--gn1",
required=True,
type=Path,
help="Path to the first GN binary (current).",
)
parser.add_argument(
"--gn2",
required=True,
type=Path,
help="Path to the second GN binary (modified).",
)
parser.add_argument(
"--fuchsia-dir",
type=Path,
help="Fuchsia directory.",
)
parser.add_argument(
"--out-dir",
type=Path,
help="Output directory for comparison copies (defaults to <fuchsia-dir>/out/compare_gn).",
)
parser.add_argument(
"--config-count",
type=int,
help="Limit the comparison to a fixed number of configurations to save time.",
)
parser.add_argument(
"--config-filter",
help="Regex filter for configuration names (e.g. 'core.x64').",
)
args = parser.parse_args()
if not args.fuchsia_dir:
fuchsia_dir_env = os.getenv("FUCHSIA_DIR")
if not fuchsia_dir_env:
print(
"Error: --fuchsia-dir or FUCHSIA_DIR env var is required.",
file=sys.stderr,
)
return 1
fuchsia_dir = Path(fuchsia_dir_env)
else:
fuchsia_dir = args.fuchsia_dir
fuchsia_dir = fuchsia_dir.resolve()
gn1 = args.gn1.resolve()
gn2 = args.gn2.resolve()
if not gn1.exists():
print(f"Error: GN1 binary not found at {gn1}", file=sys.stderr)
return 1
if not gn2.exists():
print(f"Error: GN2 binary not found at {gn2}", file=sys.stderr)
return 1
out_dir = args.out_dir or fuchsia_dir / "out/compare_gn"
out_dir = out_dir.resolve()
out_dir.mkdir(parents=True, exist_ok=True)
print(f"Fuchsia Dir: {fuchsia_dir}")
print(f"GN1: {gn1}")
print(f"GN2: {gn2}")
print(f"Out Dir: {out_dir}")
configs = find_fint_params(fuchsia_dir)
print(f"Found {len(configs)} configurations.")
valid_configs = [c for c in configs if check_config_valid(fuchsia_dir, c)]
print(
f"Valid configurations (matching current checkout): {len(valid_configs)}"
)
if args.config_filter:
filter_re = re.compile(args.config_filter)
valid_configs = [c for c in valid_configs if filter_re.search(c.stem)]
print(
f"Filtered to {len(valid_configs)} configurations matching"
f" '{args.config_filter}'."
)
if args.config_count is not None:
valid_configs = valid_configs[: args.config_count]
print(
f"Limiting comparison to the first {args.config_count} valid"
" configurations."
)
if not valid_configs:
print("No valid configurations to test.")
return 0
with tempfile.TemporaryDirectory() as tmp_dir1:
tmp_dir = Path(tmp_dir1)
fint_path = bootstrap_fint(fuchsia_dir, tmp_dir)
# The report is a list of (config_name, message, error_list) tuples
report: list[tuple[str, str, list[str]]] = []
for config in valid_configs:
config_name = config.stem
print(f"\n--- Processing {config_name} ---")
# Run directory MUST be directly under out/ to avoid nesting issues
run_dir = out_dir.parent / "compare_gn.run"
run_dir_template = out_dir.parent / "compare_gn.template"
gn1_dir = out_dir / f"{config_name}.1"
gn2_dir = out_dir / f"{config_name}.2"
# Cleanup previous runs
for d in [run_dir, run_dir_template, gn1_dir, gn2_dir]:
if d.exists():
shutil.rmtree(d)
try:
# 1. Initialize with fint set in run_dir
context_proto = generate_context_proto(
tmp_dir, fuchsia_dir, run_dir
)
print("Initializing build directory...")
if not run_fint_set(
fint_path, config, context_proto, fuchsia_dir
):
print(
f"Skipping {config_name} due to initialization failure."
)
report.append((config_name, "INIT_FAILED", []))
continue
# Save clean state to template
if not copy_dir_reflink(run_dir, run_dir_template):
shutil.copytree(run_dir, run_dir_template)
# 2. Run GN1 in run_dir
print("Running GN1...")
try:
subprocess.run(
[str(gn1), "gen", str(run_dir)],
check=True,
capture_output=True,
text=True,
)
except subprocess.CalledProcessError as e:
print(f"GN1 failed: {e.stderr}")
report.append((config_name, "GN1_FAILED", [e.stderr]))
continue
# 3. Move GN1 output to gn1_dir
print("Saving GN1 output...")
move_dir(run_dir, gn1_dir)
# 4. Restore clean state for GN2
print("Restoring clean build directory...")
move_dir(run_dir_template, run_dir)
# 5. Run GN2 in run_dir
print("Running GN2...")
try:
subprocess.run(
[str(gn2), "gen", str(run_dir)],
check=True,
capture_output=True,
text=True,
)
except subprocess.CalledProcessError as e:
print(f"GN2 failed: {e.stderr}")
report.append((config_name, "GN2_FAILED", [e.stderr]))
continue
# 6. Move GN2 output to gn2_dir
print("Saving GN2 output...")
move_dir(run_dir, gn2_dir)
# 7. Compare
print("Comparing outputs...")
diffs = compare_directories(gn1_dir, gn2_dir)
if diffs:
print(f"Found {len(diffs)} discrepancies!")
for d in diffs:
print(f" {d}")
report.append((config_name, "DIFFERENT", diffs))
else:
print("Outputs are identical.")
report.append((config_name, "IDENTICAL", []))
finally:
# Clean up any remaining temp directories for this config (on failure/continue)
for d in [run_dir, run_dir_template]:
if d.exists():
shutil.rmtree(d)
# Generate Report
print("\n=========================================")
print(" GN COMPARISON REPORT ")
print("=========================================")
identical_count = 0
different_count = 0
failed_count = 0
for name, status, diffs in report:
if status == "IDENTICAL":
identical_count += 1
elif status == "DIFFERENT":
different_count += 1
print(f"\nConfig: {name} (DIFFERENT)")
for d in diffs:
print(f" {d}")
else:
failed_count += 1
print(f"\nConfig: {name} (FAILED: {status})")
if diffs:
print(f" Error: {diffs[0]}")
print("\nSummary:")
print(f" Identical: {identical_count}")
print(f" Different: {different_count}")
print(f" Failed: {failed_count}")
return 0
if __name__ == "__main__":
sys.exit(main())