[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