| #!/usr/bin/env vpython3 | |
| # 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. | |
| """gclient hook that repairs node_modules/ when gclient's stamps outlive it. | |
| gclient decides whether to re-extract the `node_modules` gcs dependency from | |
| the stamp files it keeps inside the extracted tree | |
| (.devtools-frontend_<sha256>_hash and friends), never from the tree itself. | |
| Checking out a revision from before node_modules left git restores the | |
| tracked tree over the (ignored) extracted files and leaves the stamps alone; | |
| checking out a later revision again deletes the tracked files. The next | |
| `gclient sync` then finds a valid stamp next to an empty directory and does | |
| nothing, and the build fails with "Cannot find module" errors. | |
| This hook detects that state and re-extracts the node_modules.tar.gz that | |
| gclient leaves next to the stamps, so that a single `gclient sync` repairs | |
| the checkout (in a Chromium checkout, too, via recursedeps). Without a | |
| matching archive it removes the stamps and fails, so that the next | |
| `gclient sync` downloads the archive again. It does nothing when gclient has | |
| not extracted anything here (fresh checkouts, Cog) or when the tree is there. | |
| (The stamps and the archive are deliberately not written as node_modules/... | |
| above: manage_node_deps.py's reference check reads every such path in a | |
| Python file as a build input that must exist in the archive.) | |
| """ | |
| import hashlib | |
| import os | |
| import re | |
| import sys | |
| import tarfile | |
| NODE_MODULES = os.path.join( | |
| os.path.dirname(os.path.dirname(os.path.dirname( | |
| os.path.abspath(__file__)))), 'node_modules') | |
| ARCHIVE_NAME = 'node_modules.tar.gz' | |
| # Present in every archive (scripts/deps/npm_exclude.txt keeps package.json | |
| # files); without TypeScript nothing builds anyway. | |
| SENTINEL = os.path.join('typescript', 'package.json') | |
| # gclient's stamp files for a gcs dependency; see GcsDependency in gclient.py. | |
| STAMP_PATTERN = re.compile(r'^\..*_(hash|is_first_class_gcs|content_names)$') | |
| def stamp_files(node_modules): | |
| """Returns the names of gclient's stamp files in node_modules.""" | |
| try: | |
| names = os.listdir(node_modules) | |
| except FileNotFoundError: | |
| return [] | |
| return sorted(name for name in names if STAMP_PATTERN.match(name)) | |
| def stamped_hashes(node_modules, stamps): | |
| """Returns the sha256 digests recorded in the *_hash stamps.""" | |
| hashes = set() | |
| for name in stamps: | |
| if name.endswith('_hash'): | |
| with open(os.path.join(node_modules, name), encoding='utf-8') as f: | |
| hashes.add(f.read().strip()) | |
| return hashes | |
| def sha256sum(filename): | |
| sha = hashlib.sha256() | |
| with open(filename, 'rb') as f: | |
| for chunk in iter(lambda: f.read(1024 * 1024), b''): | |
| sha.update(chunk) | |
| return sha.hexdigest() | |
| def extract(archive, node_modules): | |
| """Extracts the archive the way gclient does. | |
| The data filter rejects absolute paths, parent references and links that | |
| leave node_modules; dropping the archive's (zeroed) mtimes gives the | |
| files the current time, like gclient's extraction. | |
| """ | |
| def tar_filter(member, dest_path): | |
| member = tarfile.data_filter(member, dest_path) | |
| if member is not None: | |
| member.mtime = None | |
| return member | |
| with tarfile.open(archive, 'r:*') as tar: | |
| if hasattr(tarfile, 'data_filter'): | |
| tar.extractall(path=node_modules, filter=tar_filter) | |
| else: | |
| tar.extractall(path=node_modules) | |
| def ensure_node_modules(node_modules): | |
| """Returns the process exit code.""" | |
| stamps = stamp_files(node_modules) | |
| if not stamps: | |
| # gclient has not extracted anything here; nothing to repair. | |
| return 0 | |
| sentinel = os.path.join(node_modules, SENTINEL) | |
| if os.path.exists(sentinel): | |
| return 0 | |
| archive = os.path.join(node_modules, ARCHIVE_NAME) | |
| if os.path.isfile(archive) and sha256sum(archive) in stamped_hashes( | |
| node_modules, stamps): | |
| print(f'{node_modules} is incomplete ({SENTINEL} is missing) although ' | |
| 'gclient has extracted it before, typically after checking out ' | |
| 'a revision from before node_modules moved out of git. ' | |
| f'Re-extracting {ARCHIVE_NAME}.') | |
| try: | |
| extract(archive, node_modules) | |
| except (tarfile.TarError, OSError) as error: | |
| print(f'Extracting {archive} failed: {error}', file=sys.stderr) | |
| else: | |
| if os.path.exists(sentinel): | |
| return 0 | |
| print( | |
| f'{archive} does not contain {SENTINEL}; update SENTINEL in ' | |
| f'{os.path.abspath(__file__)}.', | |
| file=sys.stderr) | |
| return 1 | |
| # No usable archive: drop the stamps so that the next sync downloads it. | |
| for name in stamps + [ARCHIVE_NAME]: | |
| try: | |
| os.remove(os.path.join(node_modules, name)) | |
| except FileNotFoundError: | |
| pass | |
| print( | |
| f'{node_modules} is incomplete ({SENTINEL} is missing) and ' | |
| f'{ARCHIVE_NAME} is missing, does not match its stamp or failed to ' | |
| 'extract. Removed the stale stamps; run `gclient sync` again to ' | |
| 'download node_modules.', | |
| file=sys.stderr) | |
| return 1 | |
| def main(argv): | |
| node_modules = argv[1] if len(argv) > 1 else NODE_MODULES | |
| return ensure_node_modules(os.path.abspath(node_modules)) | |
| if __name__ == '__main__': | |
| sys.exit(main(sys.argv)) |