Files
brave-core/tools/cr/patchfile.py
T
cdesouza-chromium 66cc1c82b3 [canary][cr140] Fix 3way conflict parsing with whitespace warnigns (#29951)
Recently, when running the lift for `140.0.7281.1..140.0.7283.1`,
brockit was listing one of the files as just `errors.`. This was
happening because the `git apply` command was producing a last line with
a warning, like this:

```
brave/patches/chrome-android-java-AndroidManifest.xml.patch:81: trailing whitespace.
        <!--
Applied patch to 'chrome/android/java/AndroidManifest.xml' with conflicts.
U chrome/android/java/AndroidManifest.xml
warning: 1 line adds whitespace errors.
```

This change adds a test to cover a case like this, and corrects how
brockit is scraping the `apply` output, making sure we just don't assume
the source path is always produced in the last line.

Resolves https://github.com/brave/brave-browser/issues/47453
2025-07-08 16:31:34 +01:00

314 lines
13 KiB
Python

# Copyright (c) 2025 The Brave Authors. All rights reserved.
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this file,
# You can obtain one at https://mozilla.org/MPL/2.0/.
from dataclasses import dataclass, field, replace
from enum import Enum, auto
import logging
from pathlib import PurePath, Path
import re
import subprocess
from typing import Optional, NamedTuple
from repository import Repository
import repository
@dataclass(frozen=True)
class Patchfile:
"""Patchfile data class to hold the patchfile path.
This class provides methods to manage the information regarding individual
patches, such as the source file name, the repository, and the status of the
patch application, etc.
"""
# A patch's path and file name, from `patchPath`.
# e.g. "patchPath":"patches/build-android-gyp-dex.py.patch"
path: PurePath
# This field holds the name of the source file as provided by
# `update_patches`, which is not a primary source.
# e.g. "path":"build/android/gyp/dex.py"
provided_source: Optional[str] = field(default=None)
# This field holds the most reliable resolution to the source file name,
# as its value is assigned from a git operation.
source_from_git: Optional[str] = field(default=None)
# The repository the patch file is targeting to patch.
repository: Repository = field(init=False)
def __post_init__(self):
object.__setattr__(self, 'repository',
self.get_repository_from_patch_name())
if (not self.repository.is_chromium and self.provided_source
and self.provided_source.startswith(
self.repository.relative_to_chromium.as_posix())):
# The report produced by `apply_patches` provides the source as a
# full path relative to chromium's src/ directory, therefore it is
# necessary to strip the repository path from the source path.
object.__setattr__(
self, 'provided_source',
PurePath(self.provided_source).relative_to(
self.repository.relative_to_chromium).as_posix())
def get_repository_from_patch_name(self) -> Repository:
"""Gets the repository for the patch file.
"""
if self.path.suffix != '.patch':
raise ValueError(
f'Patch file name should end with `.patch`. {self.path}')
if (self.path.is_absolute() or len(self.path.parents) < 2
or self.path.parents[-2].stem != "patches"):
raise ValueError(
f'Patch file name should start with `patches/`. {self.path}')
# Drops `patches/` at the beginning and the filename at the end.
return Repository(
repository.CHROMIUM_SRC_PATH.joinpath(*self.path.parts[1:-1]))
class ApplyStatus(Enum):
"""The result of applying the patch.
"""
# The patch was applied successfully.
CLEAN = auto()
# The patch was applied with conflicts.
CONFLICT = auto()
# The patch could not be applied because the source file was deleted.
DELETED = auto()
# The patch failed as broken.
BROKEN = auto()
class ApplyResult(NamedTuple):
"""The result of applying the patch.
"""
# The status of the patch operation
status: "ApplyStatus"
# A copy of the patch file data, with updated information.
patch: Optional["Patchfile"]
@dataclass
class SourceStatus:
"""The status of the source file in a given commit.
"""
# A code for the status of the source file. (e.g 'R', 'M', 'D')
status: str
# The commit details for this source file status.
commit_details: str
# The name the source may have been renamed to.
renamed_to: Optional[str] = None
def source_name_from_patch_naming(self) -> str:
"""Source file name according to the patch file name.
This function uses the patch file name to deduce the source file name.
This works in general, but it is not the most reliable method to
determine the source file name, and it should be used only in cases
where git, and `apply_patches` cannot provide the source file name.
e.g. "patches/build-android-gyp-dex.py.patch" ->
"brave/build/android/gyp/dex.py"
"""
return self.path.name[:-len(".patch")].replace('-', '/')
def source(self) -> PurePath:
"""The source file name for the patch file.
Tries to use the most reliable source file name, going from git, to the
one provide by `apply_patches`, to finally deducing from the name.
"""
if self.source_from_git is not None:
return self.source_from_git
if self.provided_source is not None:
return self.provided_source
return self.source_name_from_patch_naming()
def source_from_brave(self) -> PurePath:
"""The source file path relative to the `brave/` directory.
"""
return self.repository.from_brave() / self.source()
def path_from_repo(self) -> PurePath:
"""The patch path relative to the repository source belongs.
"""
return self.repository.to_brave() / self.path
def apply(self) -> ApplyResult:
"""Applies the patch file with `git apply --3way`.
This function applies the patch file with `git apply --3way` to the
repository, and it returns the status of the operation.
Returns:
The result of the patch application, and an updated patch instance
with the source file name, if any is provided by git.
"""
try:
self.repository.run_git('apply', '--3way', '--ignore-space-change',
'--ignore-whitespace',
self.path_from_repo().as_posix())
return self.ApplyResult(status=self.ApplyStatus.CLEAN, patch=None)
except subprocess.CalledProcessError as e:
# If the process fails, we need to collect the files that failed to
# apply for manual conflict resolution.
if 'with conflicts' in e.stderr:
# Output in this case should look like:
# Applied patch to 'build/android/gyp/dex.py' with conflicts.
# U build/android/gyp/dex.py
# Find the line starting with 'U ' to get the conflicted file
# path from.
conflict_line = next(
(line for line in e.stderr.strip().splitlines()
if line.startswith('U ')), None)
if conflict_line:
conflicted_file = conflict_line.split()[-1]
else:
raise NotImplementedError(
'Could not find conflicted file in error '
f'output: {e.stderr}') from e
return self.ApplyResult(status=self.ApplyStatus.CONFLICT,
patch=replace(
self,
source_from_git=conflicted_file))
if e.stderr.startswith('error:'):
[_, reason] = e.stderr.strip().split(': ', 1)
if 'does not exist in index' in reason:
# This type of detection could occur in certain cases when
# `npm run init` or `sync` were not run for the working
# branch. It may be useful to warn.
#
# It is also of notice that this error can also occur when
# `apply` is run twice for the same patch with conflicts.
logging.warning(
'Patch with missing file detected during --3way apply,'
' which may indicate a bad sync state before starting '
'to upgrade. %s', self.path)
return self.ApplyResult(
status=self.ApplyStatus.DELETED,
patch=replace(self,
source_from_git=reason.split(': ',
2)[0]))
if ('No such file or directory' in reason
and self.path.as_posix() in reason):
# This should never occur as it indicates that the patch
# file itself is missing, which is sign something is wrong
# with path resolution.
raise e
# All other errors are considered broken patches.
if ('patch with only garbage' not in reason
and 'corrupt patch at line' not in reason):
# Not clear if we could have other reasons for broken
# patches, but it is better to flag it to keep an eye out
# for it.
logging.warning(
'Patch being flagged as broken, but with unexpected '
'reason: %s %s', self.path, reason)
return self.ApplyResult(status=self.ApplyStatus.BROKEN,
patch=None)
raise NotImplementedError()
def fetch_source_from_git(self) -> "Patchfile":
"""Gets the source file name from git.
This function uses git to get the source file name for the patch file,
but only if such name has not been retrieved yet from git.
Returns:
A patch instance with the source file name from git.
"""
if self.source_from_git is not None:
return self
# The command below has an output similar to:
# 8 0 base/some_file.cc
return replace(
self,
source_from_git=self.repository.run_git(
'apply', '--numstat', '-z',
self.path_from_repo().as_posix()).strip().split()[2][:-1])
def get_last_commit_for_source(self) -> str:
"""Gets the last commit where the source file was mentioned.
This function uses git to get the last commit where the source file was
mentioned, which can be used to check details of when a source was
deleted, or renamed.
Returns:
The commit hash with the last mention for the source.
"""
return self.repository.run_git('log', '--full-history', '--pretty=%h',
'-1', '--', self.source())
def get_source_removal_status(self, commit: str) -> SourceStatus:
"""Gets the status of the source file in a given commit.
This function retrieves the details of the source file in a given
commit. This is useful to check if a file has been deleted, or
renamed.
Args:
commit:
The commit hash to check the source file status.
Returns:
The status of the source file in the commit, which also includes
the commit description.
"""
# Not very sure why, but by passing `--no-commit-id` the
# output looks something like this:
# M chrome/VERSION
# commit 17bb6c858e818e81744de42ed292b7060bc341e5
# Author: Chrome Release Bot (LUCI)
# Date: Wed Feb 26 10:17:33 2025 -0800
#
# Incrementing VERSION to 134.0.6998.45
#
# Change-Id: I6e995e1d7aed40e553d3f51e98fb773cd75
#
# So the output is read and split from the moment a line starts with
# `commit`.
change = self.repository.run_git('show', '--name-status',
'--no-commit-id', commit)
[all_status, commit_details] = re.split(r'(?=^commit\s)',
change,
flags=re.MULTILINE)
# let's look for the line about the source we care about.
status_line = next(
(s for s in all_status.splitlines() if str(self.source()) in s),
None)
status_code = status_line[0]
if status_code == 'D':
return self.SourceStatus(status=status_code,
commit_details=commit_details)
if status_code == 'R':
# For renames the output looks something like:
# R100 base/some_file.cc base/renamed_to_file.cc
return self.SourceStatus(status=status_code,
commit_details=commit_details,
renamed_to=status_line.split()[-1])
# This could change in the future, but for now it only makes sense to
# use this function for deleted or renamed files.
raise NotImplementedError(f'unreachable: {status_line}')