#!/usr/bin/env python3 # ------------------------------------------------------------------------ # Copyright (c) 2021 Jason Stephenson <jason@sigio.com> # # Permission to use, copy, modify, and distribute this software for any # purpose with or without fee is hereby granted, provided that the above # copyright notice and this permission notice appear in all copies. # # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. # ------------------------------------------------------------------------ import argparse, contextlib, pathlib, re, sys @contextlib.contextmanager def smart_open(filename=None, mode='r'): """Open a file and return its handle, or return a handle to stdin or to stdout depending on filename and mode.""" if filename: fh = open(filename, mode) else: if mode is None or mode == '' or 'r' in mode: fh = sys.stdin else: fh = sys.stdout try: yield fh finally: if filename: fh.close() def ignore_patch(filename, ignore_list): """Check if filename matches a pattern in the ignore list. Return True if it does or False otherwise.""" result = False for pat in ignore_list: if re.search(pat, filename): result = True break return result def err_exit(msg): """Print a message and quit.""" print(msg, file=sys.stderr) print("Exiting...", file=sys.stderr) quit() def mkdir_if_not_exists(dirPath): """Make the directory represented by the pathlib.Path dirPath if it does not exist.""" if not dirPath.exists(): dirPath.mkdir(parents=True) def copy_file(source, destination): """Copy a file from the source Path to destination Path. Return True on success and False on failure.""" success = False if source.is_file(): mkdir_if_not_exists(destination.parent) with source.open(mode='rb') as inh: with destination.open(mode='wb') as outh: outh.write(inh.read()) success = True if success: stat = source.stat() destination.chmod(stat.st_mode) else: print("{} does not exist.".format(source), file=sys.stderr) return success def write_patch(outPath, patch): """Write patch data to the file at outPath with .patch appended to the name. Return True on success and False on failure.""" success = False mkdir_if_not_exists(outPath.parent) if outPath.suffix != ".patch": fname = outPath.name + ".patch" outPath = outPath.with_name(fname) with outPath.open(mode='wb') as outh: outh.write(patch.text.encode()) success = True return success def print_list_to_path(list, outPath): """Print list to the file represented by outPath.""" with outPath.open(mode="w") as fh: for e in list: print(e, file=fh) class Patch: """Class to represent an individual patch as generated by git diff.""" def __init__(self, text): match = re.search("^diff --git a/(.+?) b/(.+?)\n", text) if match: self.text = text self.first = match.group(0) self.afile = match.group(1) self.bfile = match.group(2) else: raise ValueError def is_new_file(self): """Return True if this patch represents a newly created file, or False if not.""" match = re.search("^" + self.first + "new file mode", self.text) if match: return True else: return False def is_mode_change(self): """Return True if this patch includes a file mode change, or False if not.""" match = re.search("^" + self.first + "old mode [0-7]+\nnew mode [0-7]+\n", self.text) if match: return True else: return False def is_binary_file(self): """Return True if the patch is for a binary file, or False if not.""" search_str = "^" + self.first if self.is_new_file(): search_str = search_str + "new file mode .+\n" elif self.is_mode_change(): search_str = search_str + "old mode [0-7]+\nnew mode [0-7]+\n" search_str = search_str + "index .+\nBinary files .+ differ$" if re.match(search_str, self.text): return True else: return False class PatchParser: """Class to parse a file of multipe patches and iterate over each individual patch.""" def __init__(self, input_handle): self._input = input_handle self._refirst = re.compile("^diff --git a/.+? b/.+?$") self._first = None def __iter__(self): return self def __next__(self): out = None for line in self._input: if self._refirst.match(line): if out is not None: self._first = line break else: out = line else: if out is None: out = self._first out = out + line if out is None: raise StopIteration return Patch(out) if __name__ == "__main__": argparser = argparse.ArgumentParser(description=""" A simple program to split git diff patches into multiple files. """) argparser.add_argument("-f", "--file", action="store", help="filename of patch file to parse") argparser.add_argument("-i", "--ignore", action="append", help="filename patterns to ignore") argparser.add_argument("-s", "--source-dir", action="store", dest="source", default=".", help="source file directory") argparser.add_argument("-d", "--destination-dir", action="store", dest="destination", default=".", help="destination directory for split patches") argparser.add_argument("-l", "--write-lists", action="store_true", dest="lists", help="write patches.list and files.list to root of destination directory") args = argparser.parse_args() if args.source == args.destination: err_exit("source and destination are the same: {}".format(args.source)) sourceDir = pathlib.Path(args.source).expanduser() if not sourceDir.exists(): err_exit("source directory ({}) does not exist".format(args.source)) destDir = pathlib.Path(args.destination).expanduser() mkdir_if_not_exists(destDir) patchList = [] filesList = [] with smart_open(filename=args.file) as input: parser = PatchParser(input) count = 0 for patch in parser: if args.ignore and ignore_patch(patch.bfile, args.ignore): continue count = count + 1 print("{} {}".format(count, patch.bfile)) if patch.is_new_file() or patch.is_binary_file(): if copy_file(sourceDir / patch.bfile, destDir / patch.bfile): if args.lists: filesList.append(patch.bfile) else: patchName = patch.bfile + ".patch" if write_patch(destDir / patchName, patch): if args.lists: patchList.append(patchName) if len(patchList): print_list_to_path(patchList, destDir / "patches.list") if len(filesList): print_list_to_path(filesList, destDir / "files.list")