blob: 56b4e9d11a0804bbbe98adc3a02f55982e06c0a0 [file]
#!/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))