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'.
This commit is contained in:
parent
53a0c9cc2e
commit
d0f4f363d2
1 changed files with 337 additions and 0 deletions
337
dev-utils/git-date.py
Executable file
337
dev-utils/git-date.py
Executable file
|
@ -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:
|
||||
# <https://sourceforge.net/p/flightgear/mailman/message/37004175/>.
|
||||
|
||||
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<label>\w+)=(?P<path>.*)", elt)
|
||||
if mo is None or repoArgsAreJustPaths:
|
||||
label = "Repo {}".format(repoNum)
|
||||
path = elt
|
||||
else:
|
||||
label, path = mo.group("label", "path")
|
||||
res[label] = Repository(label, path)
|
||||
|
||||
return res
|
||||
|
||||
|
||||
def main():
|
||||
global params
|
||||
|
||||
locale.setlocale(locale.LC_ALL, '')
|
||||
# Require Python 3.6 or later because we rely on the retained order for
|
||||
# keyword arguments passed to the OrderedDict constructor.
|
||||
if sys.hexversion < 0x030600F0:
|
||||
sys.exit(f"{PROGNAME}: exiting because Python >= 3.6 is required.")
|
||||
|
||||
params = processCommandLineAndConfigFile()
|
||||
commitFinder = CommitFinder(params.branch, params.date)
|
||||
commitFinder.action(params.repositories)
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__": main()
|
Loading…
Reference in a new issue