hgext/chainsaw.py
author Georges Racinet <georges.racinet@octobus.net>
Fri, 23 Feb 2024 11:41:55 +0100
changeset 51433 d36a81d70f25
parent 51431 fe68a2dc0bf2
child 51434 dd519ea71416
permissions -rw-r--r--
chainsaw-update: taking care of initial cloning Perhaps we should go just a bit lower level than this `instance()`, since the main added value in our use-case is full path resolution, that we need to do anyway for the rmtree cleanup.

# chainsaw.py
#
# Copyright 2022 Georges Racinet <georges.racinet@octobus.net>
#
# This software may be used and distributed according to the terms of the
# GNU General Public License version 2 or any later version.
"""chainsaw is a collection of single-minded and dangerous tools. (EXPERIMENTAL)

  "Don't use a chainsaw to cut your food!"

The chainsaw extension provides commands that are so much geared towards a
specific use case in a specific context or environment that they are totally
inappropriate and **really dangerous** in other contexts.

The help text of each command explicitly summarizes its context of application
and the wanted end result.

It is recommended to run these commands with the ``HGPLAIN`` environment
variable (see :hg:`help scripting`).
"""

import shutil

from mercurial.i18n import _
from mercurial import (
    cmdutil,
    commands,
    error,
    localrepo,
    registrar,
)
from mercurial.utils import (
    urlutil,
)

cmdtable = {}
command = registrar.command(cmdtable)
# Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
# extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
# be specifying the version(s) of Mercurial they are tested with, or
# leave the attribute unspecified.
testedwith = b'ships-with-hg-core'


@command(
    b'admin::chainsaw-update',
    [
        (
            b'',
            b'purge-unknown',
            True,
            _(
                b'Remove unversioned files before update. Disabling this can '
                b'in some cases interfere with the update.'
                b'See also :hg:`purge`.'
            ),
        ),
        (
            b'',
            b'purge-ignored',
            True,
            _(
                b'Remove ignored files before update. Disable this for '
                b'instance to reuse previous compiler object files. '
                b'See also :hg:`purge`.'
            ),
        ),
        (
            b'',
            b'rev',
            b'',
            _(b'revision to update to'),
        ),
        (
            b'',
            b'source',
            b'',
            _(b'repository to clone from'),
        ),
        (
            b'',
            b'dest',
            b'',
            _(b'repository to update to REV (possibly cloning)'),
        ),
        (
            b'',
            b'initial-clone-minimal',
            False,
            _(
                b'Pull only the prescribed revision upon initial cloning. '
                b'This has the side effect of ignoring clone-bundles, '
                b'which if often slower on the client side and stressful '
                b'to the server than applying available clone bundles.'
            ),
        ),
    ],
    _(
        b'hg admin::chainsaw-update [OPTION] --rev REV --source SOURCE --dest DEST'
    ),
    helpbasic=True,
    norepo=True,
)
def update(ui, **opts):
    """pull and update to a given revision, no matter what, (EXPERIMENTAL)

    Context of application: *some* Continuous Integration (CI) systems,
    packaging or deployment tools.

    Wanted end result: local repository at the given REPO_PATH, having the
    latest changes to the given revision and with a clean working directory
    updated at the given revision.

    chainsaw-update pulls from one source, then updates the working directory
    to the given revision, overcoming anything that would stand in the way.

    By default, it will:

    - clone if the local repo does not exist yet, **removing any directory
      at the given path** that would not be a Mercurial repository.
      The initial clone is full by default, so that clonebundles can be
      applied. Use the --initial-clone-minimal flag to avoid this.
    - break locks if needed, leading to possible corruption if there
      is a concurrent write access.
    - perform recovery actions if needed
    - revert any local modification.
    - purge unknown and ignored files.
    - go as far as to reclone if everything else failed (not implemented yet).

    DO NOT use it for anything else than performing a series
    of unattended updates, with full exclusive repository access each time
    and without any other local work than running build scripts.
    In case the local repository is a share (see :hg:`help share`), exclusive
    write access to the share source is also mandatory.

    It is recommended to run these commands with the ``HGPLAIN`` environment
    variable (see :hg:`scripting`).

    Motivation: in Continuous Integration and Delivery systems (CI/CD), the
    occasional remnant or bogus lock are common sources of waste of time (both
    working time and calendar time). CI/CD scripts tend to grow with counter-
    measures, often done in urgency. Also, whilst it is neat to keep
    repositories from one job to the next (especially with large
    repositories), an exceptional recloning is better than missing a release
    deadline.
    """
    rev = opts['rev']
    source = opts['source']
    repo_path = opts['dest']
    if not rev:
        raise error.InputError(_(b'specify a target revision with --rev'))
    if not source:
        raise error.InputError(_(b'specify a pull path with --source'))
    if not repo_path:
        raise error.InputError(_(b'specify a repo path with --dest'))
    repo_path = urlutil.urllocalpath(repo_path)

    try:
        repo = localrepo.instance(ui, repo_path, create=False)
        repo_created = False
        ui.status(_(b'loaded repository at "%s"\n' % repo_path))
    except error.RepoError:
        try:
            shutil.rmtree(repo_path)
        except FileNotFoundError:
            ui.status(_(b'no such directory: "%s"\n' % repo_path))
        else:
            ui.status(
                _(
                    b'removed non-repository file or directory '
                    b'at "%s"' % repo_path
                )
            )

        ui.status(_(b'creating repository at "%s"\n' % repo_path))
        repo = localrepo.instance(ui, repo_path, create=True)
        repo_created = True

    if repo.svfs.tryunlink(b'lock'):
        ui.status(_(b'had to break store lock\n'))
    if repo.vfs.tryunlink(b'wlock'):
        ui.status(_(b'had to break working copy lock\n'))

    ui.status(_(b'recovering after interrupted transaction, if any\n'))
    repo.recover()

    ui.status(_(b'pulling from %s\n') % source)
    if repo_created and not opts.get('initial_clone_minimal'):
        pull_revs = []
    else:
        pull_revs = [rev]
    overrides = {(b'ui', b'quiet'): True}
    with repo.ui.configoverride(overrides, b'chainsaw-update'):
        pull = cmdutil.findcmd(b'pull', commands.table)[1][0]
        pull(repo.ui, repo, source, rev=pull_revs, remote_hidden=False)

    purge = cmdutil.findcmd(b'purge', commands.table)[1][0]
    purge(
        ui,
        repo,
        dirs=True,
        all=opts.get('purge_ignored'),
        files=opts.get('purge_unknown'),
        confirm=False,
    )

    ui.status(_(b'updating to revision \'%s\'\n') % rev)
    update = cmdutil.findcmd(b'update', commands.table)[1][0]
    update(ui, repo, rev=rev, clean=True)

    ui.status(
        _(
            b'chainsaw-update to revision \'%s\' '
            b'for repository at \'%s\' done\n'
        )
        % (rev, repo.root)
    )