[PATCH 10/25] patman: Add a helper for managing cseries
Simon Glass
sjg at chromium.org
Sat May 10 13:05:03 CEST 2025
Add a module which includes helper functions for dealing with Cseries
objects.
Signed-off-by: Simon Glass <sjg at chromium.org>
---
tools/patman/__init__.py | 6 +-
tools/patman/cser_helper.py | 1524 +++++++++++++++++++++++++++++++++++
2 files changed, 1527 insertions(+), 3 deletions(-)
create mode 100644 tools/patman/cser_helper.py
diff --git a/tools/patman/__init__.py b/tools/patman/__init__.py
index b6db0cc9511..83ac4eabf80 100644
--- a/tools/patman/__init__.py
+++ b/tools/patman/__init__.py
@@ -1,9 +1,9 @@
# SPDX-License-Identifier: GPL-2.0+
__all__ = [
- 'checkpatch', 'cmdline', 'commit', 'control',
- 'database', 'func_test',
- 'get_maintainer', '__main__', 'patchstream', 'patchwork', 'project',
+ 'checkpatch', 'cmdline', 'commit', 'control', 'cser_helper',
+ 'database', 'func_test', 'get_maintainer', '__main__', 'patchstream',
+ 'patchwork', 'project',
'send', 'series', 'settings', 'setup', 'status', 'test_checkpatch',
'test_common', 'test_settings'
]
diff --git a/tools/patman/cser_helper.py b/tools/patman/cser_helper.py
new file mode 100644
index 00000000000..2841fcd9c20
--- /dev/null
+++ b/tools/patman/cser_helper.py
@@ -0,0 +1,1524 @@
+# SPDX-License-Identifier: GPL-2.0+
+#
+# Copyright 2025 Simon Glass <sjg at chromium.org>
+#
+"""Helper functions for handling the 'series' subcommand
+"""
+
+import asyncio
+from collections import OrderedDict, defaultdict, namedtuple
+from datetime import datetime
+import hashlib
+import os
+import re
+import sys
+import time
+from types import SimpleNamespace
+
+import aiohttp
+import pygit2
+from pygit2.enums import CheckoutStrategy
+
+from u_boot_pylib import gitutil
+from u_boot_pylib import terminal
+from u_boot_pylib import tout
+
+from patman import patchstream
+from patman.database import Database, Pcommit, SerVer
+from patman import patchwork
+from patman.series import Series
+from patman import status
+
+
+# Tag to use for Change IDs
+CHANGE_ID_TAG = 'Change-Id'
+
+# Length of hash to display
+HASH_LEN = 10
+
+# Shorter version of some states, to save horizontal space
+SHORTEN_STATE = {
+ 'handled-elsewhere': 'elsewhere',
+ 'awaiting-upstream': 'awaiting',
+ 'not-applicable': 'n/a',
+ 'changes-requested': 'changes',
+}
+
+# Summary info returned from Cseries.link_auto_all()
+AUTOLINK = namedtuple('autolink', 'name,version,link,desc,result')
+
+
+def oid(oid_val):
+ """Convert a hash string into a shortened hash
+
+ The number of hex digits git uses for showing hashes depends on the size of
+ the repo. For the purposes of showing hashes to the user in lists, we use a
+ fixed value for now
+
+ Args:
+ str or Pygit2.oid: Hash value to shorten
+
+ Return:
+ str: Shortened hash
+ """
+ return str(oid_val)[:HASH_LEN]
+
+
+def split_name_version(in_name):
+ """Split a branch name into its series name and its version
+
+ For example:
+ 'series' returns ('series', 1)
+ 'series3' returns ('series', 3)
+ Args:
+ in_name (str): Name to parse
+
+ Return:
+ tuple:
+ str: series name
+ int: series version, or None if there is none in in_name
+ """
+ m_ver = re.match(r'([^0-9]*)(\d*)', in_name)
+ version = None
+ if m_ver:
+ name = m_ver.group(1)
+ if m_ver.group(2):
+ version = int(m_ver.group(2))
+ else:
+ name = in_name
+ return name, version
+
+
+class CseriesHelper:
+ """Helper functions for Cseries
+
+ This class handles database read/write as well as operations in a git
+ directory to update series information.
+ """
+ def __init__(self, topdir=None, colour=terminal.COLOR_IF_TERMINAL):
+ """Set up a new CseriesHelper
+
+ Args:
+ topdir (str): Top-level directory of the repo
+ colour (terminal.enum): Whether to enable ANSI colour or not
+
+ Properties:
+ gitdir (str): Git directory (typically topdir + '/.git')
+ db (Database): Database handler
+ col (terminal.Colour): Colour object
+ _fake_time (float): Holds the current fake time for tests, in
+ seconds
+ _fake_sleep (func): Function provided by a test; called to fake a
+ 'time.sleep()' call and take whatever action it wants to take.
+ The only argument is the (Float) time to sleep for; it returns
+ nothing
+ loop (asyncio event loop): Loop used for Patchwork operations
+ """
+ self.topdir = topdir
+ self.gitdir = None
+ self.db = None
+ self.col = terminal.Color(colour)
+ self._fake_time = None
+ self._fake_sleep = None
+ self.fake_now = None
+ self.loop = asyncio.get_event_loop()
+
+ def open_database(self):
+ """Open the database ready for use"""
+ if not self.topdir:
+ self.topdir = gitutil.get_top_level()
+ if not self.topdir:
+ raise ValueError('No git repo detected in current directory')
+ self.gitdir = os.path.join(self.topdir, '.git')
+ fname = f'{self.topdir}/.patman.db'
+
+ # For the first instance, start it up with the expected schema
+ self.db, is_new = Database.get_instance(fname)
+ if is_new:
+ self.db.start()
+ else:
+ # If a previous test has already checked the schema, just open it
+ self.db.open_it()
+
+ def close_database(self):
+ """Close the database"""
+ if self.db:
+ self.db.close()
+
+ def commit(self):
+ """Commit changes to the database"""
+ self.db.commit()
+
+ def rollback(self):
+ """Roll back changes to the database"""
+ self.db.rollback()
+
+ def set_fake_time(self, fake_sleep):
+ """Setup the fake timer
+
+ Args:
+ fake_sleep (func(float)): Function to call to fake a sleep
+ """
+ self._fake_time = 0
+ self._fake_sleep = fake_sleep
+
+ def inc_fake_time(self, inc_s):
+ """Increment the fake time
+
+ Args:
+ inc_s (float): Amount to increment the fake time by
+ """
+ self._fake_time += inc_s
+
+ def get_time(self):
+ """Get the current time, fake or real
+
+ This function should always be used to read the time so that faking the
+ time works correctly in tests.
+
+ Return:
+ float: Fake time, if time is being faked, else real time
+ """
+ if self._fake_time is not None:
+ return self._fake_time
+ return time.monotonic()
+
+ def sleep(self, time_s):
+ """Sleep for a while
+
+ This function should always be used to sleep so that faking the time
+ works correctly in tests.
+
+ Args:
+ time_s (float): Amount of seconds to sleep for
+ """
+ print(f'Sleeping for {time_s} seconds')
+ if self._fake_time is not None:
+ self._fake_sleep(time_s)
+ else:
+ time.sleep(time_s)
+
+ def get_now(self):
+ """Get the time now
+
+ This function should always be used to read the datetime, so that
+ faking the time works correctly in tests
+
+ Return:
+ DateTime object
+ """
+ if self.fake_now:
+ return self.fake_now
+ return datetime.now()
+
+ def get_ser_ver_list(self):
+ """Get a list of patchwork entries from the database
+
+ Return:
+ list of SER_VER
+ """
+ return self.db.ser_ver_get_list()
+
+ def get_ser_ver_dict(self):
+ """Get a dict of patchwork entries from the database
+
+ Return: dict contain all records:
+ key (int): ser_ver id
+ value (SER_VER): Information about one ser_ver record
+ """
+ svlist = self.get_ser_ver_list()
+ svdict = {}
+ for sver in svlist:
+ svdict[sver.idnum] = sver
+ return svdict
+
+ def get_upstream_dict(self):
+ """Get a list of upstream entries from the database
+
+ Return:
+ OrderedDict:
+ key (str): upstream name
+ value (str): url
+ """
+ return self.db.upstream_get_dict()
+
+ def get_pcommit_dict(self, find_svid=None):
+ """Get a dict of pcommits entries from the database
+
+ Args:
+ find_svid (int): If not None, finds the records associated with a
+ particular series and version
+
+ Return:
+ OrderedDict:
+ key (int): record ID if find_svid is None, else seq
+ value (PCOMMIT): record data
+ """
+ pcdict = OrderedDict()
+ for rec in self.db.pcommit_get_list(find_svid):
+ if find_svid is not None:
+ pcdict[rec.seq] = rec
+ else:
+ pcdict[rec.idnum] = rec
+ return pcdict
+
+ def _get_series_info(self, idnum):
+ """Get information for a series from the database
+
+ Args:
+ idnum (int): Series ID to look up
+
+ Return: tuple:
+ str: Series name
+ str: Series description
+
+ Raises:
+ ValueError: Series is not found
+ """
+ return self.db.series_get_info(idnum)
+
+ def prep_series(self, name, end=None):
+ """Prepare to work with a series
+
+ Args:
+ name (str): Branch name with version appended, e.g. 'fix2'
+ end (str or None): Commit to end at, e.g. 'my_branch~16'. Only
+ commits up to that are processed. None to process commits up to
+ the upstream branch
+
+ Return: tuple:
+ str: Series name, e.g. 'fix'
+ Series: Collected series information, including name
+ int: Version number, e.g. 2
+ str: Message to show
+ """
+ ser, version = self._parse_series_and_version(name, None)
+ if not name:
+ name = self._get_branch_name(ser.name, version)
+
+ # First check we have a branch with this name
+ if not gitutil.check_branch(name, git_dir=self.gitdir):
+ raise ValueError(f"No branch named '{name}'")
+
+ count = gitutil.count_commits_to_branch(name, self.gitdir, end)
+ if not count:
+ raise ValueError('Cannot detect branch automatically: '
+ 'Perhaps use -U <upstream-commit> ?')
+
+ series = patchstream.get_metadata(name, 0, count, git_dir=self.gitdir)
+ self._copy_db_fields_to(series, ser)
+ msg = None
+ if end:
+ repo = pygit2.init_repository(self.gitdir)
+ target = repo.revparse_single(end)
+ first_line = target.message.splitlines()[0]
+ msg = f'Ending before {oid(target.id)} {first_line}'
+
+ return name, series, version, msg
+
+ def _copy_db_fields_to(self, series, in_series):
+ """Copy over fields used by Cseries from one series to another
+
+ This copes desc, idnum and name
+
+ Args:
+ series (Series): Series to copy to
+ in_series (Series): Series to copy from
+ """
+ series.desc = in_series.desc
+ series.idnum = in_series.idnum
+ series.name = in_series.name
+
+ def _handle_mark(self, branch_name, in_series, version, mark,
+ allow_unmarked, force_version, dry_run):
+ """Handle marking a series, checking for unmarked commits, etc.
+
+ Args:
+ branch_name (str): Name of branch to sync, or None for current one
+ in_series (Series): Series object
+ version (int): branch version, e.g. 2 for 'mychange2'
+ mark (bool): True to mark each commit with a change ID
+ allow_unmarked (str): True to not require each commit to be marked
+ force_version (bool): True if ignore a Series-version tag that
+ doesn't match its branch name
+ dry_run (bool): True to do a dry run
+
+ Returns:
+ Series: New series object, if the series was marked;
+ copy_db_fields_to() is used to copy fields over
+
+ Raises:
+ ValueError: Series being unmarked when it should be marked, etc.
+ """
+ series = in_series
+ if 'version' in series and int(series.version) != version:
+ msg = (f"Series name '{branch_name}' suggests version {version} "
+ f"but Series-version tag indicates {series.version}")
+ if not force_version:
+ raise ValueError(msg + ' (see --force-version)')
+
+ tout.warning(msg)
+ tout.warning(f'Updating Series-version tag to version {version}')
+ self.update_series(branch_name, series, int(series.version),
+ new_name=None, dry_run=dry_run,
+ add_vers=version)
+
+ # Collect the commits again, as the hashes have changed
+ series = patchstream.get_metadata(branch_name, 0,
+ len(series.commits),
+ git_dir=self.gitdir)
+ self._copy_db_fields_to(series, in_series)
+
+ if mark:
+ add_oid = self._mark_series(branch_name, series, dry_run=dry_run)
+
+ # Collect the commits again, as the hashes have changed
+ series = patchstream.get_metadata(add_oid, 0, len(series.commits),
+ git_dir=self.gitdir)
+ self._copy_db_fields_to(series, in_series)
+
+ bad_count = 0
+ for commit in series.commits:
+ if not commit.change_id:
+ bad_count += 1
+ if bad_count and not allow_unmarked:
+ raise ValueError(
+ f'{bad_count} commit(s) are unmarked; please use -m or -M')
+
+ return series
+
+ def _add_series_commits(self, series, svid):
+ """Add a commits from a series into the database
+
+ Args:
+ series (Series): Series containing commits to add
+ svid (int): ser_ver-table ID to use for each commit
+ """
+ to_add = [Pcommit(None, seq, commit.subject, None, commit.change_id,
+ None, None, None)
+ for seq, commit in enumerate(series.commits)]
+
+ self.db.pcommit_add_list(svid, to_add)
+
+ def get_series_by_name(self, name, include_archived=False):
+ """Get a Series object from the database by name
+
+ Args:
+ name (str): Name of series to get
+ include_archived (bool): True to search in archives series
+
+ Return:
+ Series: Object containing series info, or None if none
+ """
+ idnum = self.db.series_find_by_name(name, include_archived)
+ if not idnum:
+ return None
+ name, desc = self.db.series_get_info(idnum)
+
+ return Series.from_fields(idnum, name, desc)
+
+ def _get_branch_name(self, name, version):
+ """Get the branch name for a particular version
+
+ Args:
+ name (str): Base name of branch
+ version (int): Version number to use
+ """
+ return name + (f'{version}' if version > 1 else '')
+
+ def _ensure_version(self, ser, version):
+ """Ensure that a version exists in a series
+
+ Args:
+ ser (Series): Series information, with idnum and name used here
+ version (int): Version to check
+
+ Returns:
+ list of int: List of versions
+ """
+ versions = self._get_version_list(ser.idnum)
+ if version not in versions:
+ raise ValueError(
+ f"Series '{ser.name}' does not have a version {version}")
+ return versions
+
+ def _set_link(self, ser_id, name, version, link, update_commit,
+ dry_run=False):
+ """Add / update a series-links link for a series
+
+ Args:
+ ser_id (int): Series ID number
+ name (str): Series name (used to find the branch)
+ version (int): Version number (used to update the database)
+ link (str): Patchwork link-string for the series
+ update_commit (bool): True to update the current commit with the
+ link
+ dry_run (bool): True to do a dry run
+
+ Return:
+ bool: True if the database was update, False if the ser_id or
+ version was not found
+ """
+ if update_commit:
+ branch_name = self._get_branch_name(name, version)
+ _, ser, max_vers, _ = self.prep_series(branch_name)
+ self.update_series(branch_name, ser, max_vers, add_vers=version,
+ dry_run=dry_run, add_link=link)
+ if link is None:
+ link = ''
+ updated = 1 if self.db.ser_ver_set_link(ser_id, version, link) else 0
+ if dry_run:
+ self.rollback()
+ else:
+ self.commit()
+
+ return updated
+
+ def _get_autolink_dict(self, sdict, link_all_versions):
+ """Get a dict of ser_vers to fetch, along with their patchwork links
+
+ Note that this returns items that already have links, as well as those
+ without links
+
+ Args:
+ sdict:
+ key: series ID
+ value: Series with idnum, name and desc filled out
+ link_all_versions (bool): True to sync all versions of a series,
+ False to sync only the latest version
+
+ Return: tuple:
+ dict:
+ key (int): svid
+ value (tuple):
+ int: series ID
+ str: series name
+ int: series version
+ str: patchwork link for the series, or None if none
+ desc: cover-letter name / series description
+ """
+ svdict = self.get_ser_ver_dict()
+ to_fetch = {}
+
+ if link_all_versions:
+ for svinfo in self.get_ser_ver_list():
+ ser = sdict[svinfo.series_id]
+
+ pwc = self.get_pcommit_dict(svinfo.idnum)
+ count = len(pwc)
+ branch = self._join_name_version(ser.name, svinfo.version)
+ series = patchstream.get_metadata(branch, 0, count,
+ git_dir=self.gitdir)
+ self._copy_db_fields_to(series, ser)
+
+ to_fetch[svinfo.idnum] = (svinfo.series_id, series.name,
+ svinfo.version, svinfo.link, series)
+ else:
+ # Find the maximum version for each series
+ max_vers = self._series_all_max_versions()
+
+ # Get a list of links to fetch
+ for svid, ser_id, version in max_vers:
+ svinfo = svdict[svid]
+ ser = sdict[ser_id]
+
+ pwc = self.get_pcommit_dict(svid)
+ count = len(pwc)
+ branch = self._join_name_version(ser.name, version)
+ series = patchstream.get_metadata(branch, 0, count,
+ git_dir=self.gitdir)
+ self._copy_db_fields_to(series, ser)
+
+ to_fetch[svid] = (ser_id, series.name, version, svinfo.link,
+ series)
+ return to_fetch
+
+ def _get_version_list(self, idnum):
+ """Get a list of the versions available for a series
+
+ Args:
+ idnum (int): ID of series to look up
+
+ Return:
+ str: List of versions
+ """
+ if idnum is None:
+ raise ValueError('Unknown series idnum')
+ return self.db.series_get_version_list(idnum)
+
+ def _join_name_version(self, in_name, version):
+ """Convert a series name plus a version into a branch name
+
+ For example:
+ ('series', 1) returns 'series'
+ ('series', 3) returns 'series3'
+
+ Args:
+ in_name (str): Series name
+ version (int): Version number
+
+ Return:
+ str: associated branch name
+ """
+ if version == 1:
+ return in_name
+ return f'{in_name}{version}'
+
+ def _parse_series(self, name, include_archived=False):
+ """Parse the name of a series, or detect it from the current branch
+
+ Args:
+ name (str or None): name of series
+ include_archived (bool): True to search in archives series
+
+ Return:
+ Series: New object with the name set; idnum is also set if the
+ series exists in the database
+ """
+ if not name:
+ name = gitutil.get_branch(self.gitdir)
+ name, _ = split_name_version(name)
+ ser = self.get_series_by_name(name, include_archived)
+ if not ser:
+ ser = Series()
+ ser.name = name
+ return ser
+
+ def _parse_series_and_version(self, in_name, in_version):
+ """Parse name and version of a series, or detect from current branch
+
+ Figures out the name from in_name, or if that is None, from the current
+ branch.
+
+ Uses the version in_version, or if that is None, uses the int at the
+ end of the name (e.g. 'series' is version 1, 'series4' is version 4)
+
+ Args:
+ in_name (str or None): name of series
+ in_version (str or None): version of series
+
+ Return:
+ tuple:
+ Series: New object with the name set; idnum is also set if the
+ series exists in the database
+ int: Series version-number detected from the name
+ (e.g. 'fred' is version 1, 'fred2' is version 2)
+ """
+ name = in_name
+ if not name:
+ name = gitutil.get_branch(self.gitdir)
+ if not name:
+ raise ValueError('No branch detected: please use -s <series>')
+ name, version = split_name_version(name)
+ if not name:
+ raise ValueError(f"Series name '{in_name}' cannot be a number, "
+ f"use '<name><version>'")
+ if in_version:
+ if version and version != in_version:
+ tout.warning(
+ f"Version mismatch: -V has {in_version} but branch name "
+ f'indicates {version}')
+ version = in_version
+ if not version:
+ version = 1
+ if version > 99:
+ raise ValueError(f"Version {version} exceeds 99")
+ ser = self.get_series_by_name(name)
+ if not ser:
+ ser = Series()
+ ser.name = name
+ return ser, version
+
+ def _series_get_version_stats(self, idnum, vers):
+ """Get the stats for a series
+
+ Args:
+ idnum (int): ID number of series to process
+ vers (int): Version number to process
+
+ Return:
+ tuple:
+ str: Status string, '<accepted>/<count>'
+ OrderedDict:
+ key (int): record ID if find_svid is None, else seq
+ value (PCOMMIT): record data
+ """
+ svid, link = self._get_series_svid_link(idnum, vers)
+ pwc = self.get_pcommit_dict(svid)
+ count = len(pwc.values())
+ if link:
+ accepted = 0
+ for pcm in pwc.values():
+ accepted += pcm.state == 'accepted'
+ else:
+ accepted = '-'
+ return f'{accepted}/{count}', pwc
+
+ def get_series_svid(self, series_id, version):
+ """Get the patchwork ID of a series version
+
+ Args:
+ series_id (int): id of the series to look up
+ version (int): version number to look up
+
+ Return:
+ str: link found
+
+ Raises:
+ ValueError: No matching series found
+ """
+ return self._get_series_svid_link(series_id, version)[0]
+
+ def _get_series_svid_link(self, series_id, version):
+ """Get the patchwork ID of a series version
+
+ Args:
+ series_id (int): series ID to look up
+ version (int): version number to look up
+
+ Return:
+ tuple:
+ int: record id
+ str: link
+ """
+ recs = self.get_ser_ver(series_id, version)
+ return recs.idnum, recs.link
+
+ def get_ser_ver(self, series_id, version):
+ """Get the patchwork details for a series version
+
+ Args:
+ series_id (int): series ID to look up
+ version (int): version number to look up
+
+ Return:
+ SER_VER: Requested information
+
+ Raises:
+ ValueError: There is no matching idnum/version
+ """
+ return self.db.ser_ver_get_for_series(series_id, version)
+
+ def _prepare_process(self, name, count, new_name=None, quiet=False):
+ """Get ready to process all commits in a branch
+
+ Args:
+ name (str): Name of the branch to process
+ count (int): Number of commits
+ new_name (str or None): New name, if a new branch is to be created
+ quiet (bool): True to avoid output (used for testing)
+
+ Return: tuple:
+ pygit2.repo: Repo to use
+ pygit2.oid: Upstream commit, onto which commits should be added
+ Pygit2.branch: Original branch, for later use
+ str: (Possibly new) name of branch to process
+ list of Commit: commits to process, in order
+ pygit2.Reference: Original head before processing started
+ """
+ upstream_guess = gitutil.get_upstream(self.gitdir, name)[0]
+
+ tout.debug(f"_process_series name '{name}' new_name '{new_name}' "
+ f"upstream_guess '{upstream_guess}'")
+ dirty = gitutil.check_dirty(self.gitdir, self.topdir)
+ if dirty:
+ raise ValueError(
+ f"Modified files exist: use 'git status' to check: "
+ f'{dirty[:5]}')
+ repo = pygit2.init_repository(self.gitdir)
+
+ commit = None
+ upstream_name = None
+ if upstream_guess:
+ try:
+ upstream = repo.lookup_reference(upstream_guess)
+ upstream_name = upstream.name
+ commit = upstream.peel(pygit2.enums.ObjectType.COMMIT)
+ except KeyError:
+ pass
+ except pygit2.repository.InvalidSpecError as exc:
+ print(f"Error '{exc}'")
+ if not upstream_name:
+ upstream_name = f'{name}~{count}'
+ commit = repo.revparse_single(upstream_name)
+
+ branch = repo.lookup_branch(name)
+ if not quiet:
+ tout.info(
+ f'Checking out upstream commit {upstream_name}: '
+ f'{oid(commit.oid)}')
+
+ old_head = repo.head
+ if old_head.shorthand == name:
+ old_head = None
+ else:
+ old_head = repo.head
+
+ if new_name:
+ name = new_name
+ repo.set_head(commit.oid)
+
+ commits = []
+ cmt = repo.get(branch.target)
+ for _ in range(count):
+ commits.append(cmt)
+ cmt = cmt.parents[0]
+
+ return (repo, repo.head, branch, name, commit, list(reversed(commits)),
+ old_head)
+
+ def _pick_commit(self, repo, cmt):
+ """Apply a commit to the source tree, without committing it
+
+ _prepare_process() must be called before starting to pick commits
+
+ This function must be called before _finish_commit()
+
+ Note that this uses a cherry-pick method, creating a new tree_id each
+ time, so can make source-code changes
+
+ Args:
+ repo (pygit2.repo): Repo to use
+ cmt (Commit): Commit to apply
+
+ Return: tuple:
+ tree_id (pygit2.oid): Oid of index with source-changes applied
+ commit (pygit2.oid): Old commit being cherry-picked
+ """
+ tout.detail(f"- adding {oid(cmt.hash)} {cmt}")
+ repo.cherrypick(cmt.hash)
+ if repo.index.conflicts:
+ raise ValueError('Conflicts detected')
+
+ tree_id = repo.index.write_tree()
+ cherry = repo.get(cmt.hash)
+ tout.detail(f"cherry {oid(cherry.oid)}")
+ return tree_id, cherry
+
+ def _finish_commit(self, repo, tree_id, commit, cur, msg=None):
+ """Complete a commit
+
+ This must be called after _pick_commit().
+
+ Args:
+ repo (pygit2.repo): Repo to use
+ tree_id (pygit2.oid): Oid of index with source-changes applied; if
+ None then the existing commit.tree_id is used
+ commit (pygit2.oid): Old commit being cherry-picked
+ cur (pygit2.reference): Reference to parent to use for the commit
+ msg (str): Commit subject and message; None to use commit.message
+ """
+ if msg is None:
+ msg = commit.message
+ if not tree_id:
+ tree_id = commit.tree_id
+ repo.create_commit('HEAD', commit.author, commit.committer,
+ msg, tree_id, [cur.target])
+ return repo.head
+
+ def _finish_process(self, repo, branch, name, cur, old_head, new_name=None,
+ switch=False, dry_run=False, quiet=False):
+ """Finish processing commits
+
+ Args:
+ repo (pygit2.repo): Repo to use
+ branch (pygit2.branch): Branch returned by _prepare_process()
+ name (str): Name of the branch to process
+ new_name (str or None): New name, if a new branch is being created
+ switch (bool): True to switch to the new branch after processing;
+ otherwise HEAD remains at the original branch, as amended
+ dry_run (bool): True to do a dry run, restoring the original tree
+ afterwards
+ quiet (bool): True to avoid output (used for testing)
+
+ Return:
+ pygit2.reference: Final commit after everything is completed
+ """
+ repo.state_cleanup()
+
+ # Update the branch
+ target = repo.revparse_single('HEAD')
+ if not quiet:
+ tout.info(f'Updating branch {name} from {oid(branch.target)} to '
+ f'{str(target.oid)[:HASH_LEN]}')
+ if dry_run:
+ if new_name:
+ repo.head.set_target(branch.target)
+ else:
+ branch_oid = branch.peel(pygit2.enums.ObjectType.COMMIT).oid
+ repo.head.set_target(branch_oid)
+ repo.head.set_target(branch.target)
+ repo.set_head(branch.name)
+ else:
+ if new_name:
+ new_branch = repo.branches.create(new_name, target)
+ if branch.upstream:
+ new_branch.upstream = branch.upstream
+ branch = new_branch
+ else:
+ branch.set_target(cur.target)
+ repo.set_head(branch.name)
+ if old_head:
+ if not switch:
+ repo.set_head(old_head.name)
+ return target
+
+ def make_change_id(self, commit):
+ """Make a Change ID for a commit
+
+ This is similar to the gerrit script:
+ git var GIT_COMMITTER_IDENT ; echo "$refhash" ; cat "README"; }
+ | git hash-object --stdin)
+
+ Args:
+ commit (pygit2.commit): Commit to process
+
+ Return:
+ Change ID in hex format
+ """
+ sig = commit.committer
+ val = hashlib.sha1()
+ to_hash = f'{sig.name} <{sig.email}> {sig.time} {sig.offset}'
+ val.update(to_hash.encode('utf-8'))
+ val.update(str(commit.tree_id).encode('utf-8'))
+ val.update(commit.message.encode('utf-8'))
+ return val.hexdigest()
+
+ def _filter_commits(self, name, series, seq_to_drop):
+ """Filter commits to drop one
+
+ This function rebases the current branch, dropping a single commit,
+ thus changing the resulting code in the tree.
+
+ Args:
+ name (str): Name of the branch to process
+ series (Series): Series object
+ seq_to_drop (int): Commit sequence to drop; commits are numbered
+ from 0, which is the one after the upstream branch, to
+ count - 1
+ """
+ count = len(series.commits)
+ (repo, cur, branch, name, commit, _, _) = self._prepare_process(
+ name, count, quiet=True)
+ repo.checkout_tree(commit, strategy=CheckoutStrategy.FORCE |
+ CheckoutStrategy.RECREATE_MISSING)
+ repo.set_head(commit.oid)
+ for seq, cmt in enumerate(series.commits):
+ if seq != seq_to_drop:
+ tree_id, cherry = self._pick_commit(repo, cmt)
+ cur = self._finish_commit(repo, tree_id, cherry, cur)
+ self._finish_process(repo, branch, name, cur, None, quiet=True)
+
+ def process_series(self, name, series, new_name=None, switch=False,
+ dry_run=False):
+ """Rewrite a series commit messages, leaving code alone
+
+ This uses a 'vals' namespace to pass things to the controlling
+ function.
+
+ Each time _process_series() yields, it sets up:
+ commit (Commit): The pygit2 commit that is being processed
+ msg (str): Commit message, which can be modified
+ info (str): Initially empty; the controlling function can add a
+ short message here which will be shown to the user
+ final (bool): True if this is the last commit to apply
+ seq (int): Current sequence number in the commits to apply (0,,n-1)
+
+ It also sets git HEAD at the commit before this commit being
+ processed
+
+ The function can change msg and info, e.g. to add or remove tags from
+ the commit.
+
+ Args:
+ name (str): Name of the branch to process
+ series (Series): Series object
+ new_name (str or None): New name, if a new branch is to be created
+ switch (bool): True to switch to the new branch after processing;
+ otherwise HEAD remains at the original branch, as amended
+ dry_run (bool): True to do a dry run, restoring the original tree
+ afterwards
+
+ Return:
+ pygit.oid: oid of the new branch
+ """
+ count = len(series.commits)
+ repo, cur, branch, name, _, commits, old_head = self._prepare_process(
+ name, count, new_name)
+ vals = SimpleNamespace()
+ vals.final = False
+ tout.info(f"Processing {count} commits from branch '{name}'")
+
+ # Record the message lines
+ lines = []
+ for seq, cmt in enumerate(series.commits):
+ commit = commits[seq]
+ vals.commit = commit
+ vals.msg = commit.message
+ vals.info = ''
+ vals.final = seq == len(series.commits) - 1
+ vals.seq = seq
+ yield vals
+
+ cur = self._finish_commit(repo, None, commit, cur, vals.msg)
+ lines.append([vals.info.strip(),
+ f'{oid(cmt.hash)} as {oid(cur.target)} {cmt}'])
+
+ max_len = max(len(info) for info, rest in lines) + 1
+ for info, rest in lines:
+ if info:
+ info += ':'
+ tout.info(f'- {info.ljust(max_len)} {rest}')
+ target = self._finish_process(repo, branch, name, cur, old_head,
+ new_name, switch, dry_run)
+ vals.oid = target.oid
+
+ def _mark_series(self, name, series, dry_run=False):
+ """Mark a series with Change-Id tags
+
+ Args:
+ name (str): Name of the series to mark
+ series (Series): Series object
+ dry_run (bool): True to do a dry run, restoring the original tree
+ afterwards
+
+ Return:
+ pygit.oid: oid of the new branch
+ """
+ vals = None
+ for vals in self.process_series(name, series, dry_run=dry_run):
+ if CHANGE_ID_TAG not in vals.msg:
+ change_id = self.make_change_id(vals.commit)
+ vals.msg = vals.msg + f'\n{CHANGE_ID_TAG}: {change_id}'
+ tout.detail(" - adding mark")
+ vals.info = 'marked'
+ else:
+ vals.info = 'has mark'
+
+ return vals.oid
+
+ def update_series(self, branch_name, series, max_vers, new_name=None,
+ dry_run=False, add_vers=None, add_link=None,
+ add_rtags=None, switch=False):
+ """Rewrite a series to update the Series-version/Series-links lines
+
+ This updates the series in git; it does not update the database
+
+ Args:
+ branch_name (str): Name of the branch to process
+ series (Series): Series object
+ max_vers (int): Version number of the series being updated
+ new_name (str or None): New name, if a new branch is to be created
+ dry_run (bool): True to do a dry run, restoring the original tree
+ afterwards
+ add_vers (int or None): Version number to add to the series, if any
+ add_link (str or None): Link to add to the series, if any
+ add_rtags (list of dict): List of review tags to add, one item for
+ each commit, each a dict:
+ key: Response tag (e.g. 'Reviewed-by')
+ value: Set of people who gave that response, each a name/email
+ string
+ switch (bool): True to switch to the new branch after processing;
+ otherwise HEAD remains at the original branch, as amended
+
+ Return:
+ pygit.oid: oid of the new branch
+ """
+ def _do_version():
+ if add_vers:
+ if add_vers == 1:
+ vals.info += f'rm v{add_vers} '
+ else:
+ vals.info += f'add v{add_vers} '
+ out.append(f'Series-version: {add_vers}')
+
+ def _do_links(new_links):
+ if add_link:
+ if 'add' not in vals.info:
+ vals.info += 'add '
+ vals.info += f"links '{new_links}' "
+ else:
+ vals.info += f"upd links '{new_links}' "
+ out.append(f'Series-links: {new_links}')
+
+ added_version = False
+ added_link = False
+ for vals in self.process_series(branch_name, series, new_name, switch,
+ dry_run):
+ out = []
+ for line in vals.msg.splitlines():
+ m_ver = re.match('Series-version:(.*)', line)
+ m_links = re.match('Series-links:(.*)', line)
+ if m_ver and add_vers:
+ if ('version' in series and
+ int(series.version) != max_vers):
+ tout.warning(
+ f'Branch {branch_name}: Series-version tag '
+ f'{series.version} does not match expected '
+ f'version {max_vers}')
+ _do_version()
+ added_version = True
+ elif m_links:
+ links = series.get_links(m_links.group(1), max_vers)
+ if add_link:
+ links[max_vers] = add_link
+ _do_links(series.build_links(links))
+ added_link = True
+ else:
+ out.append(line)
+ if vals.final:
+ if not added_version and add_vers and add_vers > 1:
+ _do_version()
+ if not added_link and add_link:
+ _do_links(f'{max_vers}:{add_link}')
+
+ vals.msg = '\n'.join(out) + '\n'
+ if add_rtags and add_rtags[vals.seq]:
+ lines = []
+ for tag, people in add_rtags[vals.seq].items():
+ for who in people:
+ lines.append(f'{tag}: {who}')
+ vals.msg = patchstream.insert_tags(vals.msg.rstrip(),
+ sorted(lines))
+ vals.info += (f'added {len(lines)} '
+ f"tag{'' if len(lines) == 1 else 's'}")
+
+ def _build_col(self, state, prefix='', base_str=None):
+ """Build a patch-state string with colour
+
+ Args:
+ state (str): State to colourise (also indicates the colour to use)
+ prefix (str): Prefix string to also colourise
+ base_str (str or None): String to show instead of state, or None to
+ show state
+
+ Return:
+ str: String with ANSI colour characters
+ """
+ bright = True
+ if state == 'accepted':
+ col = self.col.GREEN
+ elif state == 'awaiting-upstream':
+ bright = False
+ col = self.col.GREEN
+ elif state in ['changes-requested']:
+ col = self.col.CYAN
+ elif state in ['rejected', 'deferred', 'not-applicable', 'superseded',
+ 'handled-elsewhere']:
+ col = self.col.RED
+ elif not state:
+ state = 'unknown'
+ col = self.col.MAGENTA
+ else:
+ # under-review, rfc, needs-review-ack
+ col = self.col.WHITE
+ out = base_str or SHORTEN_STATE.get(state, state)
+ pad = ' ' * (10 - len(out))
+ col_state = self.col.build(col, prefix + out, bright)
+ return col_state, pad
+
+ def _get_patches(self, series, version):
+ """Get a Series object containing the patches in a series
+
+ Args:
+ series (str): Name of series to use, or None to use current branch
+ version (int): Version number, or None to detect from name
+
+ Return: tuple:
+ str: Name of branch, e.g. 'mary2'
+ Series: Series object containing the commits and idnum, desc, name
+ int: Version number of series, e.g. 2
+ OrderedDict:
+ key (int): record ID if find_svid is None, else seq
+ value (PCOMMIT): record data
+ str: series name (for this version)
+ str: patchwork link
+ str: cover_id
+ int: cover_num_comments
+ """
+ ser, version = self._parse_series_and_version(series, version)
+ if not ser.idnum:
+ raise ValueError(f"Unknown series '{series}'")
+ self._ensure_version(ser, version)
+ svinfo = self.get_ser_ver(ser.idnum, version)
+ pwc = self.get_pcommit_dict(svinfo.idnum)
+
+ count = len(pwc)
+ branch = self._join_name_version(ser.name, version)
+ series = patchstream.get_metadata(branch, 0, count,
+ git_dir=self.gitdir)
+ self._copy_db_fields_to(series, ser)
+
+ return (branch, series, version, pwc, svinfo.name, svinfo.link,
+ svinfo.cover_id, svinfo.cover_num_comments)
+
+ def _list_patches(self, branch, pwc, series, desc, cover_id, num_comments,
+ show_commit, show_patch, list_patches, state_totals):
+ """List patches along with optional status info
+
+ Args:
+ branch (str): Branch name if self.show_progress
+ pwc (dict): pcommit records:
+ key (int): seq
+ value (PCOMMIT): Record from database
+ series (Series): Series to show, or None to just use the database
+ desc (str): Series title
+ cover_id (int): Cover-letter ID
+ num_comments (int): The number of comments on the cover letter
+ show_commit (bool): True to show the commit and diffstate
+ show_patch (bool): True to show the patch
+ list_patches (bool): True to list all patches for each series,
+ False to just show the series summary on a single line
+ state_totals (dict): Holds totals for each state across all patches
+ key (str): state name
+ value (int): Number of patches in that state
+
+ Return:
+ bool: True if OK, False if any commit subjects don't match their
+ patchwork subjects
+ """
+ lines = []
+ states = defaultdict(int)
+ count = len(pwc)
+ ok = True
+ for seq, item in enumerate(pwc.values()):
+ if series:
+ cmt = series.commits[seq]
+ if cmt.subject != item.subject:
+ ok = False
+
+ col_state, pad = self._build_col(item.state)
+ patch_id = item.patch_id if item.patch_id else ''
+ if item.num_comments:
+ comments = str(item.num_comments)
+ elif item.num_comments is None:
+ comments = '-'
+ else:
+ comments = ''
+
+ if show_commit or show_patch:
+ subject = self.col.build(self.col.BLACK, item.subject,
+ bright=False, back=self.col.YELLOW)
+ else:
+ subject = item.subject
+
+ line = (f'{seq:3} {col_state}{pad} {comments.rjust(3)} '
+ f'{patch_id:7} {oid(cmt.hash)} {subject}')
+ lines.append(line)
+ states[item.state] += 1
+ out = ''
+ for state, freq in states.items():
+ out += ' ' + self._build_col(state, f'{freq}:')[0]
+ state_totals[state] += freq
+ name = ''
+ if not list_patches:
+ name = desc or series.desc
+ name = self.col.build(self.col.YELLOW, name[:41].ljust(41))
+ if not ok:
+ out = '*' + out[1:]
+ print(f"{branch:16} {name} {len(pwc):5} {out}")
+ return ok
+ print(f"Branch '{branch}' (total {len(pwc)}):{out}{name}")
+
+ print(self.col.build(
+ self.col.MAGENTA,
+ f"Seq State Com PatchId {'Commit'.ljust(HASH_LEN)} Subject"))
+
+ comments = '' if num_comments is None else str(num_comments)
+ if desc or comments or cover_id:
+ cov = 'Cov' if cover_id else ''
+ print(self.col.build(
+ self.col.WHITE,
+ f"{cov:14} {comments.rjust(3)} {cover_id or '':7} "
+ f'{desc or series.desc}',
+ bright=False))
+ for seq in range(count):
+ line = lines[seq]
+ print(line)
+ if show_commit or show_patch:
+ print()
+ cmt = series.commits[seq] if series else ''
+ msg = gitutil.show_commit(
+ cmt.hash, show_commit, True, show_patch,
+ colour=self.col.enabled(), git_dir=self.gitdir)
+ sys.stdout.write(msg)
+ if seq != count - 1:
+ print()
+ print()
+
+ return ok
+
+ def _find_matched_commit(self, commits, pcm):
+ """Find a commit in a list of possible matches
+
+ Args:
+ commits (dict of Commit): Possible matches
+ key (int): sequence number of patch (from 0)
+ value (Commit): Commit object
+ pcm (PCOMMIT): Patch to check
+
+ Return:
+ int: Sequence number of matching commit, or None if not found
+ """
+ for seq, cmt in commits.items():
+ tout.debug(f"- match subject: '{cmt.subject}'")
+ if pcm.subject == cmt.subject:
+ return seq
+ return None
+
+ def _find_matched_patch(self, patches, cmt):
+ """Find a patch in a list of possible matches
+
+ Args:
+ patches: dict of ossible matches
+ key (int): sequence number of patch
+ value (PCOMMIT): patch
+ cmt (Commit): Commit to check
+
+ Return:
+ int: Sequence number of matching patch, or None if not found
+ """
+ for seq, pcm in patches.items():
+ tout.debug(f"- match subject: '{pcm.subject}'")
+ if cmt.subject == pcm.subject:
+ return seq
+ return None
+
+ def _sync_one(self, svid, series_name, version, show_comments,
+ show_cover_comments, gather_tags, cover, patches, dry_run):
+ """Sync one series to the database
+
+ Args:
+ svid (int): Ser/ver ID
+ cover (dict or None): Cover letter from patchwork, with keys:
+ id (int): Cover-letter ID in patchwork
+ num_comments (int): Number of comments
+ name (str): Cover-letter name
+ patches (list of Patch): Patches in the series
+ """
+ pwc = self.get_pcommit_dict(svid)
+ if gather_tags:
+ count = len(pwc)
+ branch = self._join_name_version(series_name, version)
+ series = patchstream.get_metadata(branch, 0, count,
+ git_dir=self.gitdir)
+
+ _, new_rtag_list = status.do_show_status(
+ series, cover, patches, show_comments, show_cover_comments,
+ self.col, warnings_on_stderr=False)
+ self.update_series(branch, series, version, None, dry_run,
+ add_rtags=new_rtag_list)
+
+ updated = 0
+ for seq, item in enumerate(pwc.values()):
+ if seq >= len(patches):
+ continue
+ patch = patches[seq]
+ if patch.id:
+ if self.db.pcommit_update(
+ Pcommit(item.idnum, seq, None, None, None, patch.state,
+ patch.id, len(patch.comments))):
+ updated += 1
+ if cover:
+ info = SerVer(svid, None, None, None, cover.id,
+ cover.num_comments, cover.name, None)
+ else:
+ info = SerVer(svid, None, None, None, None, None, patches[0].name,
+ None)
+ self.db.ser_ver_set_info(info)
+
+ return updated, 1 if cover else 0
+
+ async def _gather(self, pwork, link, show_cover_comments):
+ """Sync the series status from patchwork
+
+ Creates a new client sesion and calls _sync()
+
+ Args:
+ pwork (Patchwork): Patchwork object to use
+ link (str): Patchwork link for the series
+ show_cover_comments (bool): True to show the comments on the cover
+ letter
+
+ Return: tuple:
+ COVER object, or None if none or not read_cover_comments
+ list of PATCH objects
+ """
+ async with aiohttp.ClientSession() as client:
+ return await pwork.series_get_state(client, link, True,
+ show_cover_comments)
+
+ def _get_fetch_dict(self, sync_all_versions):
+ """Get a dict of ser_vers to fetch, along with their patchwork links
+
+ Args:
+ sync_all_versions (bool): True to sync all versions of a series,
+ False to sync only the latest version
+
+ Return: tuple:
+ dict: things to fetch
+ key (int): svid
+ value (str): patchwork link for the series
+ int: number of series which are missing a link
+ """
+ missing = 0
+ svdict = self.get_ser_ver_dict()
+ sdict = self.db.series_get_dict_by_id()
+ to_fetch = {}
+
+ if sync_all_versions:
+ for svinfo in self.get_ser_ver_list():
+ ser_ver = svdict[svinfo.idnum]
+ if svinfo.link:
+ to_fetch[svinfo.idnum] = patchwork.STATE_REQ(
+ svinfo.link, svinfo.series_id,
+ sdict[svinfo.series_id].name, svinfo.version, False,
+ False)
+ else:
+ missing += 1
+ else:
+ # Find the maximum version for each series
+ max_vers = self._series_all_max_versions()
+
+ # Get a list of links to fetch
+ for svid, series_id, version in max_vers:
+ ser_ver = svdict[svid]
+ if series_id not in sdict:
+ # skip archived item
+ continue
+ if ser_ver.link:
+ to_fetch[svid] = patchwork.STATE_REQ(
+ ser_ver.link, series_id, sdict[series_id].name,
+ version, False, False)
+ else:
+ missing += 1
+
+ # order by series name, version
+ ordered = OrderedDict()
+ for svid in sorted(
+ to_fetch,
+ key=lambda k: (to_fetch[k].series_name, to_fetch[k].version)):
+ sync = to_fetch[svid]
+ ordered[svid] = sync
+
+ return ordered, missing
+
+ async def _sync_all(self, client, pwork, to_fetch):
+ """Sync all series status from patchwork
+
+ Args:
+ pwork (Patchwork): Patchwork object to use
+ sync_all_versions (bool): True to sync all versions of a series,
+ False to sync only the latest version
+ gather_tags (bool): True to gather review/test tags
+
+ Return: list of tuple:
+ COVER object, or None if none or not read_cover_comments
+ list of PATCH objects
+ """
+ with pwork.collect_stats() as stats:
+ tasks = [pwork.series_get_state(client, sync.link, True, True)
+ for sync in to_fetch.values() if sync.link]
+ result = await asyncio.gather(*tasks)
+ return result, stats.request_count
+
+ async def _do_series_sync_all(self, pwork, to_fetch):
+ async with aiohttp.ClientSession() as client:
+ return await self._sync_all(client, pwork, to_fetch)
+
+ def _progress_one(self, ser, show_all_versions, list_patches,
+ state_totals):
+ """Show progress information for all versions in a series
+
+ Args:
+ ser (Series): Series to use
+ show_all_versions (bool): True to show all versions of a series,
+ False to show only the final version
+ list_patches (bool): True to list all patches for each series,
+ False to just show the series summary on a single line
+ state_totals (dict): Holds totals for each state across all patches
+ key (str): state name
+ value (int): Number of patches in that state
+
+ Return: tuple
+ int: Number of series shown
+ int: Number of patches shown
+ int: Number of version which need a 'scan'
+ """
+ max_vers = self._series_max_version(ser.idnum)
+ name, desc = self._get_series_info(ser.idnum)
+ coloured = self.col.build(self.col.BLACK, desc, bright=False,
+ back=self.col.YELLOW)
+ versions = self._get_version_list(ser.idnum)
+ vstr = list(map(str, versions))
+
+ if list_patches:
+ print(f"{name}: {coloured} (versions: {' '.join(vstr)})")
+ add_blank_line = False
+ total_series = 0
+ total_patches = 0
+ need_scan = 0
+ for ver in versions:
+ if not show_all_versions and ver != max_vers:
+ continue
+ if add_blank_line:
+ print()
+ _, pwc = self._series_get_version_stats(ser.idnum, ver)
+ count = len(pwc)
+ branch = self._join_name_version(ser.name, ver)
+ series = patchstream.get_metadata(branch, 0, count,
+ git_dir=self.gitdir)
+ svinfo = self.get_ser_ver(ser.idnum, ver)
+ self._copy_db_fields_to(series, ser)
+
+ ok = self._list_patches(
+ branch, pwc, series, svinfo.name, svinfo.cover_id,
+ svinfo.cover_num_comments, False, False, list_patches,
+ state_totals)
+ if not ok:
+ need_scan += 1
+ add_blank_line = list_patches
+ total_series += 1
+ total_patches += count
+ return total_series, total_patches, need_scan
+
+ def _summary_one(self, ser):
+ """Show summary information for the latest version in a series
+
+ Args:
+ series (str): Name of series to use, or None to show progress for
+ all series
+ """
+ max_vers = self._series_max_version(ser.idnum)
+ name, desc = self._get_series_info(ser.idnum)
+ stats, pwc = self._series_get_version_stats(ser.idnum, max_vers)
+ states = {x.state for x in pwc.values()}
+ state = 'accepted'
+ for val in ['awaiting-upstream', 'changes-requested', 'rejected',
+ 'deferred', 'not-applicable', 'superseded',
+ 'handled-elsewhere']:
+ if val in states:
+ state = val
+ state_str, pad = self._build_col(state, base_str=name)
+ print(f"{state_str}{pad} {stats.rjust(6)} {desc}")
+
+ def _series_max_version(self, idnum):
+ """Find the latest version of a series
+
+ Args:
+ idnum (int): Series ID to look up
+
+ Return:
+ int: maximum version
+ """
+ return self.db.series_get_max_version(idnum)
+
+ def _series_all_max_versions(self):
+ """Find the latest version of all series
+
+ Return: list of:
+ int: ser_ver ID
+ int: series ID
+ int: Maximum version
+ """
+ return self.db.series_get_all_max_versions()
--
2.43.0
More information about the U-Boot
mailing list