blob: dae7883c8937653979ea9e062f9932350a4a1344 [file] [edit]
#!/usr/bin/env python3
#
# Updates upstream ANGLE into WebKit's Source/ThirdParty/ANGLE.
#
# Example use:
# cd ~
# git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
# export PATH=$PATH:$HOME/depot_tools
#
# cd ~/WebKit/OpenSource
# Tools/Scripts/update-angle --work-dir /tmp/angle-update
# When it stops for conflict resolution:
# a) open a tool to resolve it:
# code /tmp/angle-update/merge
# b) .. or resolve it from command line:
# cd /tmp/angle-update/merge && git status
# ...
# git add ...
# Continue the update:
# cd ~/WebKit/OpenSource
# Tools/Scripts/update-angle --continue
#
# Phases (see PHASES). The run is resumable: each phase records progress in a
# state file at the WebKit root, so a pause (merge conflicts) or failure can be
# resumed with --continue, or discarded with --abort.
#
# prepare Create a temp upstream checkout, fetch upstream, and resolve the
# previous commit (from ANGLE.plist) and the target commit.
# sync Write a .gclient and run `gclient sync` to populate third_party.
# Syncs at the previous revision (to capture the vendored merge
# base) and then at the target revision.
# merge 3-way merge upstream into WebKit's tree (may pause on conflicts).
# See the "Merge" section below for how/why this works.
# thirdparty Bring the vendored third_party subset (VENDORED) from the sync
# into the merged tree, 3-way-merging WebKit's local edits to
# vendored text files (binary/data files are overwritten).
# codegen_a Run upstream scripts/run_code_generation.py on the merged tree.
# codegen_b Regenerate WebKit-specific files: angle_commit.h, the *.cmake
# build files, and ANGLEShaderProgramVersion.h.
# bookkeep Update ANGLE.plist and changes.diff, and write
# the commit-message draft.
# finalize Export the merged tree into Source/ThirdParty/ANGLE, stage it,
# and print the remaining manual steps.
#
# python3 -m autopep8 --in-place --max-line-length 200 Tools/Scripts/update-angle
#
import argparse
import glob
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
SCRIPT_DIR = os.path.dirname(os.path.realpath(__file__))
WEBKIT_ROOT = os.path.realpath(os.path.join(SCRIPT_DIR, "..", ".."))
ANGLE_REL = os.path.join("Source", "ThirdParty", "ANGLE")
ANGLE_DIR = os.path.join(WEBKIT_ROOT, ANGLE_REL)
COMMIT_MSG_FILE = os.path.join(WEBKIT_ROOT, "angle-update.COMMIT_EDITMSG")
STATE_FILE = os.path.join(WEBKIT_ROOT, "update-angle-state.txt")
UPSTREAM_URL = "https://chromium.googlesource.com/angle/angle"
PHASES = [
"prepare",
"sync",
"merge",
"thirdparty",
"codegen_a",
"codegen_b",
"bookkeep",
"finalize",
]
# Explicitly vendored third_party source: dropped from the main merge, then
# brought in from the synced checkout by the `thirdparty` phase, which 3-way
# merges WebKit's local edits to vendored text files (binary/data overwritten).
# Extend as the build needs more upstream third_party sources.
_MUSTPASS = ("third_party/VK-GL-CTS/src/external/openglcts/data/gl_cts/data/"
"mustpass/gles/aosp_mustpass/main")
VENDORED = [
"src/third_party/khronos/**",
"src/third_party/volk/**",
"src/third_party/ceval/**",
"src/third_party/libXNVCtrl/**",
"third_party/VK-GL-CTS/src/framework/**",
"third_party/VK-GL-CTS/src/execserver/**",
"third_party/VK-GL-CTS/src/executor/**",
"third_party/VK-GL-CTS/src/modules/gles2/**",
"third_party/VK-GL-CTS/src/modules/gles3/**",
"third_party/VK-GL-CTS/src/modules/glshared/**",
"third_party/VK-GL-CTS/src/data/gles2/**",
"third_party/VK-GL-CTS/src/data/gles3/**",
_MUSTPASS + "/gles2-main.txt",
_MUSTPASS + "/gles3-main.txt",
]
# Everything else dropped from the merge: WebKit-owned files (must never be
# touched by the merge), generated files (regenerated by codegen_b), and
# upstream paths WebKit never builds.
DROP = [
# WebKit-owned.
"ANGLE.plist",
"ANGLE.xcodeproj/**",
"Configurations/**",
"**/CMakeLists.txt",
"*.cmake",
"linux.cmake",
"Makefile",
"Android.mk",
"adjust-angle-include-paths*",
"gni-to-cmake.py",
"ANGLEPrefix.h",
"changes.diff",
"WebKit/**",
# Upstream paths WebKit never builds.
"third_party/*/src/**",
"src/third_party/**",
"infra/**",
"parsetab.py",
"**/__pycache__/**",
"**/.DS_Store",
"third_party/googletest/**",
"third_party/kotlin_stdlib*",
"third_party/r8/custom_d8.jar",
"third_party/re2/**",
]
_DROP_PATHSPECS = [":(glob)" + p for p in VENDORED + DROP]
# Pathspec excludes for changes.diff: the :(exclude,glob) form of the same set.
_CHANGES_DIFF_EXCLUDES = [":(exclude,glob)" + p for p in VENDORED + DROP]
VERBOSE = False
QUIET = False
def log(msg):
"""Progress output. Suppressed in --quiet mode."""
if QUIET:
return
print(("[update-angle] " + msg) if VERBOSE else msg, flush=True)
def warn(msg):
"""Warning. Always printed, even in --quiet mode."""
print("[update-angle] WARNING: " + msg, flush=True)
def vlog(msg):
if VERBOSE:
log(msg)
def run(cmd, cwd=None, env=None, check=True, capture=False):
vlog("$ " + " ".join(cmd) + (" (cwd=%s)" % cwd if cwd else ""))
if capture:
return subprocess.run(cmd, cwd=cwd, env=env, check=check,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True)
return subprocess.run(cmd, cwd=cwd, env=env, check=check)
def git(args, cwd, check=True, capture=True, index_file=None, strip=True):
env = None
if index_file is not None:
env = dict(os.environ)
env["GIT_INDEX_FILE"] = index_file
# Stable identity (env beats git config) for every git invocation, so any
# commit -- commit-tree, the `git merge` auto-commit (via gitcode), and the
# post-resolution commit -- never prompts or fails for lack of one.
ident = {
"GIT_AUTHOR_NAME": "ANGLE Update",
"GIT_AUTHOR_EMAIL": "update-angle@webkit.org",
"GIT_COMMITTER_NAME": "ANGLE Update",
"GIT_COMMITTER_EMAIL": "update-angle@webkit.org",
"GIT_AUTHOR_DATE": "2000-01-01T00:00:00Z",
"GIT_COMMITTER_DATE": "2000-01-01T00:00:00Z",
}
if env is None:
env = dict(os.environ)
env.update(ident)
try:
res = run(["git"] + args, cwd=cwd, env=env, check=check, capture=True)
except subprocess.CalledProcessError as e:
fail("git %s failed (exit %d):\n%s"
% (" ".join(args), e.returncode, (e.stderr or "").strip()))
if capture:
out = res.stdout if res.stdout is not None else ""
# strip=False preserves exact bytes, e.g. the leading space of a
# worktree-only `git status --porcelain` line.
return out.strip() if strip else out
return res
def gitcode(args, cwd):
"""Run git via git() and return its exit code (never raises)."""
return git(args, cwd, check=False, capture=False).returncode
class State:
def __init__(self):
self.path = STATE_FILE
self.data = {}
def load(self):
if os.path.exists(self.path):
with open(self.path) as f:
self.data = json.load(f)
return self.data
def save(self):
with open(self.path, "w") as f:
json.dump(self.data, f, indent=2)
def remove(self):
if os.path.exists(self.path):
os.remove(self.path)
def set_phase(self, phase):
self.data["phase"] = phase
self.save()
def fail(msg):
print("[update-angle] ERROR: " + msg, file=sys.stderr, flush=True)
sys.exit(1)
class Paused(Exception):
def __init__(self, phase, instructions):
self.phase = phase
self.instructions = instructions
def materialize(work, commit):
"""Build a tree object from `commit`, dropping the VENDORED + DROP paths
and submodule gitlinks (keeping what gets merged). Returns the tree SHA.
Uses a scratch index, no working tree."""
scratch = os.path.join(work, ".materialize.index")
if os.path.exists(scratch):
os.remove(scratch)
git(["read-tree", commit], cwd=work, index_file=scratch)
remove = set()
# Dropped paths: matched by git's own glob engine via _DROP_PATHSPECS.
matched = git(["ls-files", "-z", "--"] + _DROP_PATHSPECS,
cwd=work, index_file=scratch, strip=False)
remove.update(p for p in matched.split("\0") if p)
# Submodule gitlinks (mode 160000) are always dropped -- WebKit never uses
# git submodules in its ANGLE copy, and a gitlink has no children, so the
# path globs above can't catch it. ls-files -s exposes the mode.
for line in git(["ls-files", "-s"], cwd=work, index_file=scratch).split("\n"):
if not line:
continue
meta, path = line.split("\t", 1)
if meta.split(" ", 1)[0] == "160000":
remove.add(path)
vlog("materialize(%s): dropping %d entries" % (commit[:10], len(remove)))
if remove:
# Feed paths NUL-separated to avoid arg-length / quoting issues.
env = dict(os.environ)
env["GIT_INDEX_FILE"] = scratch
subprocess.run(
["git", "update-index", "-z", "--force-remove", "--stdin"],
cwd=work, env=env, input="\0".join(remove) + "\0", text=True,
check=True)
tree = git(["write-tree"], cwd=work, index_file=scratch)
os.remove(scratch)
return tree
def phase_prepare(ctx):
"""Create the temp upstream checkout, fetch upstream, resolve commits,
wire WebKit's object store as an alternate so we can read WebKit's ANGLE
tree without fetching all of WebKit's history."""
work = ctx["work"]
os.makedirs(work, exist_ok=True)
if not os.path.exists(os.path.join(work, ".git")):
git(["init", "-q", work], cwd=WEBKIT_ROOT)
git(["remote", "add", "upstream", ctx["upstream_url"]], cwd=work)
# Diff drivers used by ANGLE's .gitattributes (*.mm -> objcpp,
# *.h -> objcppheader) so merge/diff hunk headers (xfuncname) are
# formatted correctly. Shared by the merge worktree via the common
# config.
git(["config", "diff.objcpp.xfuncname", r"^[-+@a-zA-Z_].*$"], cwd=work)
git(["config", "diff.objcppheader.xfuncname", r"^[@a-zA-Z_].*$"], cwd=work)
# Read the previously-imported upstream commit from ANGLE.plist.
plist = os.path.join(ANGLE_DIR, "ANGLE.plist")
with open(plist) as f:
m = re.search(r"[a-f0-9]{40}", f.read())
if not m:
fail("Could not find previous upstream hash in ANGLE.plist")
prev_hash = m.group(0)
log("Fetching upstream ANGLE (target=%s) ..." % ctx["target"])
git(["fetch", "-q", "upstream", "main"], cwd=work)
# The previous commit is normally an ancestor of main and thus already
# present. Only fetch it if missing, and WITHOUT --depth: a shallow repo
# would truncate `git rev-list HEAD --count`, giving a wrong
# ANGLE_COMMIT_POSITION in angle_commit.h.
if gitcode(["cat-file", "-e", prev_hash + "^{commit}"], work) != 0:
gitcode(["fetch", "-q", "upstream", prev_hash], work)
# The target is usually upstream/main (just fetched). If a specific commit
# not reachable from main was requested, fetch it explicitly.
target = ctx["target"]
if gitcode(["rev-parse", "-q", "--verify", target + "^{commit}"], work) != 0:
gitcode(["fetch", "-q", "upstream", target], work)
target_hash = git(["rev-parse", target], cwd=work)
prev_hash = git(["rev-parse", prev_hash], cwd=work)
# Make WebKit's objects readable from the work repo (for the `ours` tree).
alt = os.path.join(work, ".git", "objects", "info", "alternates")
webkit_objects = os.path.join(WEBKIT_ROOT, ".git", "objects")
with open(alt, "w") as f:
f.write(webkit_objects + "\n")
ctx["state"].data["previous_hash"] = prev_hash
ctx["state"].data["target_hash"] = target_hash
log("previous=%s target=%s" % (prev_hash[:12], target_hash[:12]))
# Check out the target so we can sync + run upstream codegen against it.
git(["checkout", "-q", "-f", target_hash], cwd=work)
def _is_text_file(path):
"""Heuristic: a file is text (3-way mergeable) unless it has a NUL byte."""
try:
with open(path, "rb") as f:
return b"\0" not in f.read(8192)
except OSError:
return False
def _iter_vendored(root):
"""Yield (relpath, abspath) for every VENDORED-matched file under `root`,
skipping directories and anything inside a .git."""
for pat in VENDORED:
for m in sorted(glob.glob(os.path.join(root, pat), recursive=True)):
rel = os.path.relpath(m, root)
if os.path.isfile(m) and ".git" not in rel.split(os.sep):
yield rel, m
def _gclient_sync(work):
log("Running gclient sync (this can take a while) ...")
# Hooks are kept on so clang-format is fetched for the codegen_a phase.
res = run(["gclient", "sync", "--no-history"], cwd=work, check=False)
if res.returncode != 0:
fail("gclient sync failed. Re-run with --skip-sync to vendor "
"third_party manually.")
def phase_sync(ctx):
"""Write a minimal .gclient and run gclient sync in the work checkout.
Syncs twice: first at the previous upstream revision to capture the vendored
files as they were then (the 3-way merge base used by `thirdparty`), then at
the target revision (the working state for codegen + the `theirs` vendored)."""
work = ctx["work"]
st = ctx["state"].data
if ctx["args"].skip_sync:
log("Skipping gclient sync (--skip-sync); vendored 3-way merge disabled.")
st["base_vendored_dir"] = None
return
# Disable every optional checkout so sync stays as small as possible.
custom_vars = {
"checkout_angle_mesa": False,
"checkout_angle_internal": False,
"checkout_angle_restricted_traces": False,
"checkout_angle_dawn_deps": False,
"checkout_angle_partition_alloc": False,
"checkout_angle_cl_deps": False,
}
gclient = (
"solutions = [\n"
" {\n"
" \"name\": \".\",\n"
" \"url\": \"%s.git\",\n"
" \"deps_file\": \"DEPS\",\n"
" \"managed\": False,\n"
" \"custom_vars\": %s,\n"
" },\n"
"]\n" % (ctx["upstream_url"], repr(custom_vars)) # .gclient is Python: need True/False, not JSON
)
with open(os.path.join(work, ".gclient"), "w") as f:
f.write(gclient)
# Capture the previous revision's vendored TEXT files as the 3-way merge
# base, so WebKit's local edits to vendored files survive the roll instead
# of being overwritten. Binary/data files are never base-captured (they are
# overwritten wholesale by `thirdparty`). Sync at prev, copy out, then sync
# back to the target so the rest of the run sees the target state.
base_dir = os.path.join(ctx["work_root"], "base_vendored")
if os.path.exists(base_dir):
shutil.rmtree(base_dir)
log("Syncing previous revision (%s) to capture the vendored merge base ..."
% st["previous_hash"][:12])
git(["checkout", "-q", "-f", st["previous_hash"]], cwd=work)
_gclient_sync(work)
nbase = 0
for rel, abspath in _iter_vendored(work):
if not _is_text_file(abspath):
continue
dest = os.path.join(base_dir, rel)
os.makedirs(os.path.dirname(dest), exist_ok=True)
shutil.copy2(abspath, dest)
nbase += 1
st["base_vendored_dir"] = base_dir
log("Captured %d base vendored text file(s)." % nbase)
log("Syncing target revision (%s) ..." % st["target_hash"][:12])
git(["checkout", "-q", "-f", st["target_hash"]], cwd=work)
_gclient_sync(work)
def _overlay_synced_deps(work, merge):
"""Symlink gclient-synced content that exists in WORK but not in the MERGE
worktree, so codegen generators and clang-format can find their inputs
without copying gigabytes. Returns the list of created symlink paths (merge-
relative) for cleanup. Never shadows files that already exist in MERGE."""
created = []
for dirpath, dirnames, filenames in os.walk(work):
rel = os.path.relpath(dirpath, work)
rel = "" if rel == "." else rel
if ".git" in dirnames:
dirnames.remove(".git")
for d in list(dirnames):
rsub = os.path.join(rel, d) if rel else d
if not os.path.exists(os.path.join(merge, rsub)):
os.symlink(os.path.join(dirpath, d), os.path.join(merge, rsub))
created.append(rsub)
dirnames.remove(d) # symlink covers the whole subtree
for fn in filenames:
rf = os.path.join(rel, fn) if rel else fn
mf = os.path.join(merge, rf)
if not os.path.lexists(mf):
os.symlink(os.path.join(dirpath, fn), mf)
created.append(rf)
return created
def phase_codegen_a(ctx):
"""Run upstream run_code_generation.py for real against the MERGED tree, so
generated files reflect WebKit+upstream merged sources."""
work = ctx["work"]
merge = ctx["merge"]
if ctx["args"].skip_codegen:
log("Skipping upstream codegen (--skip-codegen).")
return
script = os.path.join(merge, "scripts", "run_code_generation.py")
if not os.path.exists(script):
warn("scripts/run_code_generation.py not found; skipping.")
return
# Make the synced third_party / buildtools (and clang-format) visible.
links = _overlay_synced_deps(work, merge)
try:
log("Running upstream code generation on the merged tree ...")
res = run([sys.executable, "scripts/run_code_generation.py"],
cwd=merge, check=False)
if res.returncode != 0:
warn("run_code_generation.py returned %d; continuing with "
"merged outputs." % res.returncode)
finally:
# Remove the overlay symlinks so they never reach bookkeep/export.
for rel in links:
p = os.path.join(merge, rel)
if os.path.islink(p):
os.unlink(p)
# Merge: how upstream and WebKit content are combined, and why it works
#
# The merge operates on git *tree objects*, not on files in a checkout, so the
# fact that WebKit keeps ANGLE in a subdirectory never matters. Three trees:
#
# base = previous upstream commit, run through the path filter (materialize)
# theirs = target upstream commit, run through the SAME filter
# ours = WebKit's current ANGLE tree, read as `HEAD:Source/ThirdParty/ANGLE`
#
# `HEAD:Source/ThirdParty/ANGLE` resolves to the subtree object, whose entries
# are already ANGLE-root-relative -- byte-for-byte the same layout as upstream's
# root tree. So the two line up with no path rewriting (no git-filter-repo of
# WebKit history). `ours` is read from the WebKit repo via an alternates link
# wired up in `prepare`, so the temp work repo can see it without fetching any
# WebKit history.
#
# base/ours/theirs are wrapped into three commits that share `base` as their
# parent, then `git merge theirs` runs an ordinary 3-way merge with `base` as
# the merge base. Why it produces only real conflicts:
#
# * base and theirs go through the IDENTICAL filter (materialize drops the
# VENDORED + DROP paths and all submodule gitlinks). So anything WebKit
# excludes is absent from BOTH upstream sides -- WebKit's deletions have
# nothing to collide with, eliminating delete/modify ("deleted by them")
# conflicts. Only files WebKit actually modified can conflict.
# * theirs is the pure target tree (committed upstream generated files come
# through unchanged); WebKit-owned and generated files are dropped from the
# merge and regenerated afterwards (codegen_a/codegen_b), so they never
# conflict.
#
# `finalize` copies the merged tree back into the subdirectory -- the
# root->subdir direction is a plain directory copy, the inverse of reading the
# subtree above.
def phase_merge(ctx):
"""Stage the 3-way snapshot merge in a dedicated worktree.
See the "Merge" section comment above for how the three trees are formed
and why the merge yields only genuine (WebKit-modified) conflicts."""
work = ctx["work"]
merge = ctx["merge"]
st = ctx["state"].data
if os.path.exists(merge) and os.path.exists(os.path.join(merge, ".git")):
# Resuming: the worktree already exists.
_finish_merge(ctx)
return
prev_hash = st["previous_hash"]
log("Building filtered base / theirs trees ...")
base_tree = materialize(work, prev_hash)
# theirs is the pure target tree (committed upstream codegen outputs come
# through as-is); upstream codegen is re-run on the MERGED tree afterwards.
theirs_tree = materialize(work, st["target_hash"])
ours_tree = git(["rev-parse", "HEAD:%s" % ANGLE_REL], cwd=WEBKIT_ROOT)
base_commit = git(["commit-tree", base_tree, "-m", "base"], cwd=work)
ours_commit = git(["commit-tree", ours_tree, "-p", base_commit,
"-m", "ours"], cwd=work)
theirs_for_merge = git(["commit-tree", theirs_tree, "-p", base_commit,
"-m", "theirs"], cwd=work)
log("Creating merge worktree ...")
if os.path.exists(merge):
shutil.rmtree(merge)
git(["worktree", "add", "--no-checkout", "-f", merge, ours_commit], cwd=work)
git(["checkout", "-q", "-f", ours_commit], cwd=merge)
log("Merging upstream changes into WebKit's tree ...")
rc = gitcode(["merge", "--no-edit", theirs_for_merge], merge)
if rc != 0:
_pause_for_conflicts(ctx)
else:
log("Merge applied cleanly.")
def _pause_for_conflicts(ctx):
merge = ctx["merge"]
work = ctx["work"]
st = ctx["state"].data
conflicts = git(["diff", "--name-only", "--diff-filter=U"], cwd=merge)
lines = ["", "Merge conflicts must be resolved manually.", ""]
lines.append("Conflicted files (resolve in the merge worktree):")
lines.append(" %s" % merge)
lines.append("")
for f in conflicts.split("\n"):
if not f:
continue
lines.append(" CONFLICT: %s" % f)
# Attribution hint: which upstream commits touched this file.
upstream_log = git(
["log", "--oneline", "%s..%s" % (st["previous_hash"], st["target_hash"]),
"--", f], cwd=work, check=False)
for ll in upstream_log.split("\n")[:8]:
if ll:
lines.append(" upstream: %s" % ll)
# Attribution hint: the last couple of WebKit commits that changed this
# file, skipping the "Update ANGLE" roll commits so the actual local
# patch authors are shown.
webkit_log = git(
["log", "--oneline", "--invert-grep", "--grep=Update ANGLE", "-n", "2",
"--", os.path.join(ANGLE_REL, f)], cwd=WEBKIT_ROOT, check=False)
for ll in webkit_log.split("\n"):
if ll:
lines.append(" WebKit maybe: %s" % ll)
lines += [
"",
"In this merge, 'current' (a.k.a. 'ours', the worktree's HEAD) is",
"WebKit's tree, and 'incoming' (a.k.a. 'theirs') is upstream ANGLE.",
"To take one whole side of a conflicted file:",
" git checkout --theirs <file> # incoming (upstream) version",
" git checkout --ours <file> # current (WebKit) version",
" git add <file>",
"",
"Guidance: for patches already upstreamed, take the incoming/theirs",
"(upstream) version; for WebKit-only patches, keep the current/ours",
"(WebKit) change.",
"",
"After resolving and 'git add'-ing each file in the worktree above,",
"re-run this script with --continue.",
]
raise Paused("merge", "\n".join(lines))
def _finish_merge(ctx):
merge = ctx["merge"]
# Verify the user finished the merge.
unmerged = git(["diff", "--name-only", "--diff-filter=U"], cwd=merge)
if unmerged.strip():
raise Paused("merge",
"Still unresolved files in %s:\n %s\nResolve, 'git add', "
"then --continue." % (merge, unmerged.replace("\n", "\n ")))
if os.path.exists(os.path.join(merge, ".git")) and \
gitcode(["rev-parse", "-q", "--verify", "MERGE_HEAD"], merge) == 0:
git(["commit", "--no-edit", "--no-verify"], cwd=merge, capture=False)
log("Merge resolved.")
def phase_thirdparty(ctx):
"""Bring the vendored third_party files (VENDORED) from the target sync into
the merged tree, 3-way-merging WebKit's local edits.
After the main merge, the merge worktree holds WebKit's vendored files
(`ours`). For each vendored file in the target sync (`theirs`):
* binary/data files are overwritten wholesale (WebKit never edits them);
* text files are 3-way merged with `git merge-file ours base theirs`,
where `base` is the previous revision captured in phase_sync. This
applies upstream's changes onto WebKit's edited file, preserving the
local edits and producing conflict markers only where both sides
touched the same lines (the run then pauses for manual resolution);
* files absent from `ours` (new upstream files) are copied in.
Each entry is a glob (a 'dir/**' subtree, a wildcard, or a single file)."""
work = ctx["work"]
merge = ctx["merge"]
if ctx["args"].skip_sync:
log("Skipping third_party update (--skip-sync).")
return
base_dir = ctx["state"].data.get("base_vendored_dir")
empty = None # lazily-created empty file, used as base for files new in ours
overwritten = merged = added = 0
conflicts = []
matched_any = False
for rel, theirs in _iter_vendored(work):
matched_any = True
ours = os.path.join(merge, rel)
os.makedirs(os.path.dirname(ours), exist_ok=True)
if not os.path.exists(ours):
shutil.copy2(theirs, ours) # new upstream file
added += 1
continue
if not _is_text_file(theirs):
shutil.copy2(theirs, ours) # binary/data: take upstream
overwritten += 1
continue
base = os.path.join(base_dir, rel) if base_dir else None
if not base or not os.path.exists(base):
if empty is None:
fd, empty = tempfile.mkstemp(prefix="angle_empty_base_")
os.close(fd)
base = empty
# git merge-file applies (theirs - base) onto ours, in place.
rc = gitcode(["merge-file", "-L", "WebKit", "-L", "base", "-L", "upstream",
ours, base, theirs], merge)
if rc != 0:
conflicts.append(rel)
else:
merged += 1
if empty:
os.remove(empty)
if not matched_any:
warn("No VENDORED files matched in the sync (manifest stale?).")
log("Vendored third_party: %d merged, %d new, %d binary overwritten, "
"%d conflict(s)." % (merged, added, overwritten, len(conflicts)))
if conflicts:
lines = ["", "Vendored third_party files conflicted during the 3-way merge.",
"Resolve the conflict markers in the merge worktree, then resume:",
" %s" % merge, ""]
for c in conflicts:
lines.append(" CONFLICT: %s" % c)
fail("\n".join(lines))
def _run_webkit_source_generators(angle_dir):
"""Run the WebKit-specific generators that only need the ANGLE tree:
gni-to-cmake (*.cmake) and program_serialize_data_version
(WebKit/ANGLEShaderProgramVersion.h). Operates in `angle_dir` in place."""
# gni -> cmake build files.
gni = os.path.join(angle_dir, "gni-to-cmake.py")
if os.path.exists(gni):
conversions = [
("src/compiler.gni", "Compiler.cmake", None),
("src/libGLESv2.gni", "GLESv2.cmake", None),
("src/libANGLE/renderer/d3d/BUILD.gn", "D3D.cmake",
"src/libANGLE/renderer/d3d/"),
("src/libANGLE/renderer/gl/BUILD.gn", "GL.cmake",
"src/libANGLE/renderer/gl/"),
("src/libANGLE/renderer/metal/BUILD.gn", "Metal.cmake",
"src/libANGLE/renderer/metal/"),
]
for src, out, prepend in conversions:
if not os.path.exists(os.path.join(angle_dir, src)):
continue
# Invoke as ./gni-to-cmake.py so the path embedded in the generated
# file header matches WebKit's convention (relative, not absolute).
cmd = ["./gni-to-cmake.py", src, out]
if prepend:
cmd += ["--prepend", prepend]
run(cmd, cwd=angle_dir, check=False)
log("Regenerated *.cmake build files")
# ANGLEShaderProgramVersion.h -- hash over all ANGLE sources.
psdv = os.path.join(angle_dir, "src", "program_serialize_data_version.py")
if os.path.exists(psdv):
fd, listing = tempfile.mkstemp(prefix="angle_sources_", suffix=".txt")
os.close(fd)
with open(listing, "w") as f:
for dirpath, _dirs, files in os.walk(os.path.join(angle_dir, "src")):
for fn in files:
if fn.lower().endswith((".h", ".cpp", ".c", ".cc",
".mm", ".inc")):
f.write(os.path.relpath(os.path.join(dirpath, fn), angle_dir) + "\n")
run([sys.executable, psdv, "WebKit/ANGLEShaderProgramVersion.h", listing],
cwd=angle_dir, check=False)
os.remove(listing)
log("Regenerated WebKit/ANGLEShaderProgramVersion.h")
def phase_codegen_b(ctx):
"""Regenerate WebKit-specific generated/owned files on the merged tree."""
work = ctx["work"]
merge = ctx["merge"]
# angle_commit.h -- generated against the target commit (work HEAD).
commit_id = os.path.join(work, "src", "commit_id.py")
if os.path.exists(commit_id):
fd, tmp = tempfile.mkstemp(prefix="angle_commit_", suffix=".h")
os.close(fd)
run([sys.executable, commit_id, "gen", tmp], cwd=work, check=False)
if os.path.exists(tmp):
dest = os.path.join(merge, "WebKit", "angle_commit.h")
os.makedirs(os.path.dirname(dest), exist_ok=True)
shutil.move(tmp, dest)
log("Regenerated WebKit/angle_commit.h")
_run_webkit_source_generators(merge)
def phase_bookkeep(ctx):
"""Update ANGLE.plist and changes.diff, and write the commit message."""
work = ctx["work"]
merge = ctx["merge"]
target_hash = ctx["state"].data["target_hash"]
prev_hash = ctx["state"].data["previous_hash"]
target_date = git(["show", "-s", "--format=%cs", target_hash], cwd=work)
# ANGLE.plist: 40-char hash (appears in OpenSourceVersion and the SCM
# checkout command) + Update date (the target commit's date).
plist = os.path.join(merge, "ANGLE.plist")
if os.path.exists(plist):
with open(plist) as f:
text = f.read()
text = re.sub(r"(?<![a-f0-9])[a-f0-9]{40}(?![a-f0-9])", target_hash, text)
text = re.sub(r"<string>\d{4}-\d{2}-\d{2}</string>",
"<string>%s</string>" % target_date, text)
with open(plist, "w") as f:
f.write(text)
log("Updated ANGLE.plist")
# changes.diff: merged tree vs upstream target, restricted to the merged,
# non-excluded paths (the VENDORED + DROP set is excluded).
# --ignore-submodules=all suppresses the dropped-submodule gitlinks (the
# filter removes all submodules, but the raw target tree still has them).
git(["add", "-A"], cwd=merge)
merged_tree = git(["write-tree"], cwd=merge)
diff = git(["diff", "--ignore-submodules=all", "--full-index", "-b",
"--ignore-cr-at-eol",
target_hash, merged_tree, "--"] + _CHANGES_DIFF_EXCLUDES,
cwd=work, check=False)
with open(os.path.join(merge, "changes.diff"), "w") as f:
f.write(diff + ("\n" if diff and not diff.endswith("\n") else ""))
log("Regenerated changes.diff")
# Commit message draft at the WebKit root (copy it out and delete it).
upstream_log = git(["log", "--oneline", "%s..%s" % (prev_hash, target_hash),
"--pretty=%h %s"], cwd=work, check=False)
msg = [
"Update ANGLE to %s (%s)" % (target_date, target_hash),
"Need the bug URL (OOPS!).",
"Include a Radar link (OOPS!).",
"",
"Reviewed by NOBODY (OOPS!)",
"",
"Contains upstream commits:",
upstream_log,
"",
]
with open(COMMIT_MSG_FILE, "w") as f:
f.write("\n".join(msg))
log("Wrote %s" % os.path.relpath(COMMIT_MSG_FILE, WEBKIT_ROOT))
def phase_finalize(ctx):
"""Export the merged tree into WebKit and stage it."""
work = ctx["work"]
merge = ctx["merge"]
log("Exporting merged tree into %s ..." % ANGLE_REL)
# The set of files the merged tree should contain (worktree was `git add
# -A`-ed during bookkeep). These are repo-relative to the merge worktree.
merged_files = set(
f for f in git(["ls-files"], cwd=merge).split("\n") if f)
# Copy/update merged files into the real ANGLE dir. Crucially NO --delete:
# the real ANGLE dir may contain untracked, gitignored gclient artifacts
# (third_party submodule checkouts) that we must never destroy.
rsync = shutil.which("rsync")
if rsync:
res = run([rsync, "-a", "--exclude=.git", "--exclude=.gclient",
merge.rstrip("/") + "/", ANGLE_DIR.rstrip("/") + "/"],
check=False, capture=True)
if res.returncode != 0:
fail("rsync failed (exit %d):\n%s"
% (res.returncode, (res.stderr or "").strip()))
else:
for rel in merged_files:
src = os.path.join(merge, rel)
dst = os.path.join(ANGLE_DIR, rel)
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copy2(src, dst)
# Handle true deletions explicitly: files tracked under ANGLE in WebKit
# that are absent from the merged tree. This removes only tracked files,
# never untracked gclient content.
prefix = ANGLE_REL.replace(os.sep, "/") + "/"
tracked = git(["ls-files", ANGLE_REL], cwd=WEBKIT_ROOT).split("\n")
deletions = []
for path in tracked:
if not path:
continue
rel = path[len(prefix):] if path.startswith(prefix) else path
if rel not in merged_files:
deletions.append(path)
if deletions:
log("Removing %d file(s) deleted upstream." % len(deletions))
git(["rm", "-q", "--ignore-unmatch", "--"] + deletions,
cwd=WEBKIT_ROOT, check=False, capture=False)
# A vendored third_party subtree may sit inside a gclient-cloned git repo
# (e.g. third_party/VK-GL-CTS/src/.git from a developer's local sync). An
# embedded .git makes `git add` record a gitlink instead of the vendored
# files, so strip any .git at or above each vendored path (within ANGLE_DIR)
# before staging. The non-vendored remainder of the synced tree stays on
# disk as plain (gitignored) files; a later `gclient sync` re-clones it.
angle_root = os.path.normpath(ANGLE_DIR)
stripped = set()
for pat in VENDORED:
subdir = pat[:-len("/**")] if pat.endswith("/**") else pat
cur = os.path.normpath(os.path.join(ANGLE_DIR, subdir))
while cur != angle_root and cur.startswith(angle_root + os.sep):
dotgit = os.path.join(cur, ".git")
if cur not in stripped and os.path.lexists(dotgit):
if os.path.isdir(dotgit) and not os.path.islink(dotgit):
shutil.rmtree(dotgit, ignore_errors=True)
else:
os.remove(dotgit)
stripped.add(cur)
cur = os.path.dirname(cur)
if stripped:
log("Stripped embedded .git from %d vendored subtree root(s)."
% len(stripped))
# Stage everything (gitignored gclient artifacts are skipped by git).
# The commit message draft lives at the WebKit root, outside ANGLE_REL, so
# it is never staged.
git(["add", "-A", ANGLE_REL], cwd=WEBKIT_ROOT)
# Tear down the temp work area, unless the user gave an explicit --work-dir
# (then leave it in place for inspection / reuse).
if ctx["state"].data.get("keep_work_dir"):
log("Work dir kept at %s (user-specified --work-dir)." % ctx["work_root"])
else:
gitcode(["worktree", "remove", "--force", merge], work)
shutil.rmtree(work, ignore_errors=True)
log("")
log("ANGLE update is staged in %s." % ANGLE_REL)
log("Remaining MANUAL steps:")
log(" 1. Mirror the *.cmake changes into ANGLE.xcodeproj and make it build.")
log(" Review: git -C %s diff --staged -- %s/Compiler.cmake %s/GLESv2.cmake"
% (WEBKIT_ROOT, ANGLE_REL, ANGLE_REL))
log(" 2. make debug SCHEME='Tools (ANGLE)' && Source/ThirdParty/ANGLE/WebKit/run-angle-tests")
log(" 3. make debug && run-webkit-tests fast/canvas webgl")
log(" 4. git commit -F %s, fill in the"
% os.path.relpath(COMMIT_MSG_FILE, WEBKIT_ROOT))
log(" bug/Radar/reviewer, then delete the file before committing.")
log(" 5. update-angle --skip-sync --skip-codegen --generate-only")
PHASE_FUNCS = {
"prepare": phase_prepare,
"sync": phase_sync,
"merge": phase_merge,
"thirdparty": phase_thirdparty,
"codegen_a": phase_codegen_a,
"codegen_b": phase_codegen_b,
"bookkeep": phase_bookkeep,
"finalize": phase_finalize,
}
def require_gclient():
"""Ensure gclient (depot_tools) is on PATH; the sync/codegen phases need it.
Skipped by callers only when both --skip-sync and --skip-codegen are set."""
if shutil.which("gclient") is None:
fail("gclient not found on PATH. Add depot_tools to your PATH "
"(e.g. export PATH=\"$PATH:$HOME/depot_tools\"), or re-run with "
"--skip-sync.")
def check_clean_worktree():
# Allow uncommitted edits to this script itself, so it can be developed and
# tested without committing.
script_rel = os.path.relpath(os.path.realpath(__file__), WEBKIT_ROOT)
status = git(["status", "--porcelain"], cwd=WEBKIT_ROOT, strip=False)
dirty = []
for l in status.split("\n"):
if not l:
continue
path = l[3:]
if path == script_rel:
continue
# Dirty = tracked changes anywhere, or untracked files inside ANGLE.
if not l.startswith("??") or path.startswith(ANGLE_REL):
dirty.append(l)
if dirty:
fail("WebKit has uncommitted/untracked changes. Commit, stash, or "
"remove them first:\n" + "\n".join(dirty))
def run_from(ctx, start_phase):
start = PHASES.index(start_phase)
for phase in PHASES[start:]:
ctx["state"].set_phase(phase)
log("Phase: %s" % phase)
try:
PHASE_FUNCS[phase](ctx)
except Paused as p:
ctx["state"].set_phase(p.phase)
print(p.instructions)
sys.exit(0)
# Completed: remove state file.
ctx["state"].remove()
def _make_ctx(args, state, work_root):
return {
"args": args,
"state": state,
"work": os.path.join(work_root, "upstream"),
"merge": os.path.join(work_root, "merge"),
"work_root": work_root,
"target": args.commit,
"upstream_url": args.upstream_url,
}
DEFAULT_WORK_ROOT = os.path.join(tempfile.gettempdir(), "webkit-update-angle")
def main():
global VERBOSE, QUIET
parser = argparse.ArgumentParser(
description="Update upstream ANGLE into WebKit's Source/ThirdParty/ANGLE.")
parser.add_argument("commit", nargs="?", default="upstream/main",
help="Target upstream commit (default: upstream/main).")
parser.add_argument("-v", "--verbose", action="store_true")
parser.add_argument("-q", "--quiet", action="store_true",
help="Only print errors, warnings, and the "
"conflict-resolution note.")
parser.add_argument("--continue", dest="cont", action="store_true",
help="Resume an in-progress update (after a pause).")
parser.add_argument("--abort", action="store_true",
help="Discard the in-progress update and clean up.")
parser.add_argument("--generate-only", dest="generate_only",
action="store_true",
help="Re-run the update against the revision the tree "
"already has (from ANGLE.plist), to regenerate code "
"and changes.diff via the normal work-dir pipeline.")
parser.add_argument("--skip-sync", action="store_true",
help="Skip gclient sync and third_party update.")
parser.add_argument("--skip-codegen", action="store_true",
help="Skip upstream run_code_generation.py (Tier A).")
parser.add_argument("--work-dir", default=None,
help="Work directory (default: %s). Recorded in the "
"state file so --continue need not repeat it."
% DEFAULT_WORK_ROOT)
parser.add_argument("--upstream-url", default=UPSTREAM_URL,
help="Upstream ANGLE git URL (default: %s)." % UPSTREAM_URL)
args = parser.parse_args()
VERBOSE = args.verbose
QUIET = args.quiet
state = State()
if args.generate_only:
# Regenerate code + changes.diff by running the normal work-dir pipeline
# against the revision the tree already has (from ANGLE.plist) -- i.e. an
# update to the current revision -- rather than generating in place.
with open(os.path.join(ANGLE_DIR, "ANGLE.plist")) as f:
m = re.search(r"[a-f0-9]{40}", f.read())
if not m:
fail("Could not find current ANGLE revision in ANGLE.plist")
args.commit = m.group(0)
# Falls through to the fresh-run pipeline below (target = current rev).
if args.abort:
data = state.load() if os.path.exists(state.path) else {}
work_root = data.get("work_dir") or args.work_dir or DEFAULT_WORK_ROOT
ctx = _make_ctx(args, state, work_root)
keep = data.get("keep_work_dir") or (args.work_dir is not None)
if os.path.exists(ctx["work"]):
gitcode(["worktree", "remove", "--force", ctx["merge"]], ctx["work"])
state.remove()
if keep:
log("Aborted; state file removed. Work dir kept at %s." % work_root)
else:
shutil.rmtree(work_root, ignore_errors=True)
log("Aborted; work area %s and state file removed." % work_root)
return
if args.cont:
data = state.load()
if not data:
fail("No in-progress update found (no %s)." % state.path)
work_root = data.get("work_dir") or DEFAULT_WORK_ROOT
ctx = _make_ctx(args, state, work_root)
ctx["target"] = data.get("target_hash", ctx["target"])
# Honor the flags / locations from the original invocation.
args.skip_sync = data.get("skip_sync", args.skip_sync)
args.skip_codegen = data.get("skip_codegen", args.skip_codegen)
ctx["upstream_url"] = data.get("upstream_url", ctx["upstream_url"])
if not (args.skip_sync and args.skip_codegen):
require_gclient()
run_from(ctx, data.get("phase", "prepare"))
return
# Fresh run.
if os.path.exists(state.path):
fail("An update is already in progress (%s). Use --continue or --abort."
% state.path)
if not (args.skip_sync and args.skip_codegen):
require_gclient()
check_clean_worktree()
work_root = args.work_dir or DEFAULT_WORK_ROOT
ctx = _make_ctx(args, state, work_root)
shutil.rmtree(work_root, ignore_errors=True)
os.makedirs(work_root, exist_ok=True)
# Persist work dir + flags so --continue behaves consistently.
state.data["work_dir"] = work_root
# A user-specified --work-dir is left in place after success/--abort; only
# the default temp work dir is auto-removed.
state.data["keep_work_dir"] = args.work_dir is not None
state.data["skip_sync"] = args.skip_sync
state.data["skip_codegen"] = args.skip_codegen
state.data["upstream_url"] = ctx["upstream_url"]
state.save()
run_from(ctx, "prepare")
if __name__ == "__main__":
main()