From d0f4f363d2c1699b858d35c6649d978fcf056a74 Mon Sep 17 00:00:00 2001 From: Florent Rougon Date: Tue, 9 Mar 2021 22:28:40 +0100 Subject: [PATCH] Add script git-date.py in directory 'dev-utils' This script allows one to easily find commits in several Git repositories around a given date (in any date format accepted by Git). The repositories may be given labels for easier identification and can be defined via a configuration file and/or the command-line arguments. Option --checkout tells git-date.py to automatically perform a 'git checkout' in each of the repositories at the commits that were found. Options --show-commits and --show-commits-option instruct it to run 'git show' for each commit found with the options of your choice (all options can also be set via a configuration file). This script can be useful to find when a given regression happened in FlightGear---or any project that makes use of several Git repositories. For more details, run 'git-date.py --help'. --- dev-utils/git-date.py | 337 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 337 insertions(+) create mode 100755 dev-utils/git-date.py diff --git a/dev-utils/git-date.py b/dev-utils/git-date.py new file mode 100755 index 0000000..b58a33f --- /dev/null +++ b/dev-utils/git-date.py @@ -0,0 +1,337 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- + +# git-date.py --- Find Git commits around some date in one or more repositories. +# Copyright (c) 2021, Florent Rougon +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# +# The views and conclusions contained in the software and documentation are +# those of the authors and should not be interpreted as representing official +# policies, either expressed or implied, of the FlightGear project. + +# The idea and some Git-fu of this script are from Edward d'Auvergne: +# . + +import argparse +import locale +import os +import re +import subprocess +import sys +from collections import namedtuple, OrderedDict + +PROGNAME = os.path.basename(sys.argv[0]) +PROGVERSION = "0.2" +COPYRIGHT = "Copyright (c) 2021, Florent Rougon" +LICENSE_SUMMARY = """\ +This program is free software. It comes without any warranty, to +the extent permitted by applicable law. See the top of {progname} +for more details on the licensing conditions.""".format(progname=PROGNAME) + +# Very simple Repository class +Repository = namedtuple('Repository', ['label', 'path']) + + +class CommitFinder: + + def __init__(self, branch, date): + self.branch = branch + self.date = date + + def findCommit(self, repo_path): + """Return a commit ID that belongs to 'repo_path'.""" + args = ["git", "rev-list", "--max-count=1", + "--before={}".format(self.date), self.branch] + p = subprocess.run(args, cwd=repo_path, capture_output=True, + check=True, encoding="utf-8") + return p.stdout.strip() + + def action(self, repositories): + """Act on one or more repositories. + + 'repositories' should be an OrderedDict whose keys are + repository labels and values Repository objects. + + """ + for label, repo in repositories.items(): + commitId = self.findCommit(repo.path) + if params.let_me_breathe: self.print("-" * 78) + if not params.checkout: # the output would be redundant + if params.only_label: + # Useful with --show-commits + # --show-commits-option='--no-patch' + # --show-commits-option='--format=oneline' + self.print("{}: ".format(label), end='') + else: + self.print("{}: {}".format(label, commitId)) + if params.let_me_breathe: self.print() + + if params.show_commits: + args = ["git", "-c", "pager.show=false", "show"] + \ + params.show_commits_options + [commitId] + subprocess.run(args, cwd=repo.path, check=True) + + if params.checkout: + args = ["git", "checkout", commitId] + self.print("{}: checking out commit {}...".format(label, + commitId)) + subprocess.run(args, cwd=repo.path, check=True) + + if params.let_me_breathe: self.print() + + def print(self, *args, **kwargs): + """Wrapper for print() that defaults to flushing the output stream. + + This is particularly useful when stdout is fully buffered (e.g., + when piping the output of the script through a pager). Without + this 'flush=True' setting, output from Git commands would bypass + the high-level buffering layer in sys.stdout and could come out + before the output of some *later* non-flushed print() + statements. + """ + print(*args, flush=True, **kwargs) + + +def parseConfigFile(cfgFile, configFileOptSpecified, recognizedParams): + namespace = argparse.Namespace() + l = {} + if configFileOptSpecified or os.path.exists(cfgFile): + # Read the configuration file (i.e., execute it) + with open(cfgFile, "r") as f: + exec(f.read(), {"OrderedDict": OrderedDict}, l) + + for p in recognizedParams: + if p in l: + setattr(namespace, p, l[p]) + + return namespace + + +def processCommandLineAndConfigFile(): + defaultCfgFile = os.path.join(os.getenv('HOME'), + ".config", PROGNAME, "config.py") + parser = argparse.ArgumentParser( +usage="""\ +%(prog)s [OPTION ...] DATE [REPOSITORY...] +Find Git commits before DATE in one or more repositories.""", + description="""\ +Print information about, and possibly check out the most recent commit +before DATE in each of the specified repositories. By default, commits +are searched for in the 'next' branch, however this can be changed using +the --branch option or the 'branch' variable in the configuration file. +DATE can be in any date format accepted by Git (see the examples below). + +If option --repo-args-are-just-paths has been given, each REPOSITORY +argument is literally treated as a path to a repository. Otherwise, each +REPOSITORY argument that has the form LABEL=PATH defines a repository +rooted at PATH with associated LABEL (using this special syntax is not +mandatory, but allows {progname} to refer to your repositories using the +provided labels, which is more user-friendly in general). + +Examples (the backslashes just introduce continuation lines): + +# One output line per repository (terse) +{progname} "2021-02-28 23:12:00" SG=/path/to/SG \\ + FG=/path/to/FG FGData=/path/to/FGData + +# Ditto without providing the repository labels +{progname} "2021-02-28 23:12:00" /path/to/SG \\ + /path/to/FG /path/to/FGData + +# Run 'git show' with the specified options for each commit found. +{progname} --let-me-breathe --show-commits \\ + --show-commits-option='--no-patch' \\ + --show-commits-option='--format=medium' \\ + '2021-02-28 23:12:00' SG=/path/to/SG \\ + FG=/path/to/FG FGData=/path/to/FGData + +# Run 'git checkout' for each commit found. +{progname} --checkout --let-me-breathe "2021-01-01" SG=/path/to/SG \\ + FG=/path/to/FG FGData=/path/to/FGData + +# For each repository, print the label, commit ID and one-line description. +{progname} --only-label --show-commits \\ + --show-commits-option='--no-patch' \\ + --show-commits-option='--format=oneline' \\ + "2021-02-28" SG=/path/to/SG \\ + FG=/path/to/FG FGData=/path/to/FGData + +Note: --show-commits et al. may be used in conjunction with --checkout + if so desired. + +If $HOME/.config/{progname}/config.py exists or if the --config-file option +has been given, a configuration file is read. This file is executed by +the Python interpreter and must therefore adhere to Python 3 syntax. +Here is a sample configuration file: + +------------------------------------------------------------------------------ +branch = 'release/2020.3' +# checkout = True +# show_commits = True +# show_commits_options = ['--no-patch', '--format=medium'] +# let_me_breathe = True +# only_label = True +# repo_args_are_just_paths = True + +# collections.OrderedDict is available for use here: +repositories = OrderedDict( + SimGear = "/path/to/simgear", + FlightGear = "/path/to/flightgear", + FGData = "/path/to/fgdata") + +# Same list of repositories but without user-defined labels: +# repositories = [ +# "/path/to/simgear", +# "/path/to/flightgear", +# "/path/to/fgdata"] +------------------------------------------------------------------------------ + +Command-line options take precedence over their counterparts found in +the configuration file. On the other hand, REPOSITORY arguments *extend* +the list of repositories that may be defined in the configuration file +using the 'repositories' variable.""".format(progname=PROGNAME), + formatter_class=argparse.RawDescriptionHelpFormatter, + # I want --help but not -h (it might be useful for something else) + add_help=False) + + parser.add_argument('--repo-args-are-just-paths', + action='store_true', help="""\ + don't try to recognize and special-case LABEL=PATH syntax for + repository arguments; treat them literally as paths and simply assign + labels 'Repo 1', 'Repo 2', etc., to the specified repositories""") + parser.add_argument('-b', '--branch', default="next", help="""\ + search history of BRANCH (default: %(default)s)""") + parser.add_argument('-c', '--checkout', action='store_true', help="""\ + run 'git checkout' for the commit that was found in each repository""") + # This option is actually handled by configFileOptParser because we want to + # treat it before all other options. + parser.add_argument('--config-file', metavar="FILE", default=defaultCfgFile, + help="""\ + load configuration from FILE (default: %(default)s""") + parser.add_argument('-s', '--show-commits', action='store_true', help="""\ + run 'git show' for the commit that was found in each repository""") + parser.add_argument('-S', '--show-commits-option', action='append', + dest='show_commits_options', help="""\ + option passed to 'git show' when --show-commits is used (may be + specified multiple times, as in: --show-commits-option='--no-patch' + --show-commits-option='--format=medium')""") + parser.add_argument('--let-me-breathe', action='store_true', help="""\ + add blank lines and other separators to make the output hopefully more + readable when Git prints a lot of things""") + parser.add_argument('--only-label', action='store_true', help="""\ + don't print the commit ID after the repository label (this is useful + when the Git output coming next already contains the commit ID)""") + parser.add_argument('date', metavar="DATE", help="""\ + find commits before this date""") + parser.add_argument('cmdRepos', metavar="REPOSITORY", nargs='*', + help="""\ + path to a repository to act on (as many arguments of this type as desired + can be given)""") + parser.add_argument('--help', action="help", + help="display this message and exit") + parser.add_argument('--version', action='version', + version="{name} version {version}\n{copyright}\n\n" + "{license}".format( + name=PROGNAME, version=PROGVERSION, + copyright=COPYRIGHT, + license=LICENSE_SUMMARY)) + + # Find which config file to read and note whether the --config-file option + # was given. + configFileOptParser = argparse.ArgumentParser(add_help=False) + configFileOptParser.add_argument('--config-file') + ns, remaining = configFileOptParser.parse_known_args() + if ns.config_file is not None: + configFileOptSpecified = True + else: + configFileOptSpecified = False + ns.config_file = defaultCfgFile + + recognizedParams = ("repo_args_are_just_paths", "branch", "checkout", + "show_commits", "show_commits_options", + "let_me_breathe", "only_label", "repositories") + # Read the config file into 'params' (an argparse.Namespace object) + params = parseConfigFile(ns.config_file, configFileOptSpecified, + recognizedParams) + + # Process the rest of the command-line + parser.parse_args(namespace=params) + + if "repositories" not in params: + sys.exit(f"{PROGNAME}: no repository was specified, neither in the " + "configuration file\nnor on the command line; exiting.") + + # Prepare the final list of repositories based on the config file and the + # command line arguments. + params.repositories = initListOfRepositories( + params.repositories, params.cmdRepos, params.repo_args_are_just_paths) + return params + + +# Returns an OrderedDict whose keys are repository labels and values Repository +# objects. +def initListOfRepositories(reposFromCfgFile, reposFromCmdLineArgs, + repoArgsAreJustPaths): + res = OrderedDict() + reposLeftToAdd = [] + + if isinstance(reposFromCfgFile, OrderedDict): + for label, path in reposFromCfgFile.items(): + res[label] = Repository(label, path) + elif isinstance(reposFromCfgFile, list): + reposLeftToAdd.extend(reposFromCfgFile) + else: + sys.exit(f"{PROGNAME}: in the configuration file, 'repositories' must " + "be either an OrderedDict or a list.") + + repoNum = len(res) + for elt in reposLeftToAdd + reposFromCmdLineArgs: + repoNum += 1 + mo = re.match(r"^(?P