#! /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 type 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 (actually, each REPOSITORY argument may be of the form LABEL=PATH in order to assign a label to the repository). There can be an arbitrary number of these arguments.""") 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: params.repositories = [] # Prepare the 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) if not params.repositories: sys.exit(f"{PROGNAME}: no repository was specified, neither in the " "configuration file\nnor on the command line; exiting.") 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\nOrderedDict or a list.") repoNum = len(res) for elt in reposLeftToAdd + reposFromCmdLineArgs: repoNum += 1 mo = re.match(r"^(?P