#!/usr/bin/env python3 """Script to merge weblate changes from git(remote/master) into local git. Tails have different rules, what Weblate is allowed to do and how merge conficts should be handled: * po files only modified by Weblate(remote) - remote version is used. * po files modifed by remote and local are merged with @mergePo: - mergePo understands the po file formate and does a merge based on the po msgids. - if there is a merge conflict on one msgid (remote and local modified the content for one msgid), local is preferred. This makes sure, that Tails developers can fix issues in main repository and updates translations over Weblate. * otherwise local is preferred. """ import git import logging import logging.config import os import subprocess import tempfile import pathlib import merge_canonical_changes as mcc logger = logging.getLogger(__name__) parser = None prog = 'merge-weblate-changes' temporaryDirectory = lambda x: tempfile.TemporaryDirectory() def mergePo(origPath, lang, output): """merges po files. it expects, that @origPath is a directory with following files (pot, local, base and remote) returns the merge content to the file @output.""" pot = origPath/'pot' local = origPath/'local' base = origPath/'base' remote = origPath/'remote' with temporaryDirectory(origPath/"merge") as tempdir: path = pathlib.Path(tempdir) header = path/'header' localChanges = path/'localChanges' remoteChanges = path/'remoteChanges' unchanged = path/'unchanged' # get only header of pot file header.write_bytes(mcc.poHeader(pot.read_bytes())) # messages changed on local/remote localChanges.write_bytes(mcc.extractChanges(local, base)) remoteChanges.write_bytes(mcc.extractChanges(remote, base)) # unchanged messages unchanged.write_bytes(mcc.unchangedChanges(base, local, remote)) # messages changed on local, not on remote; and vice-versa unique = subprocess.run(['msgcat', '-o', '-', '--unique', str(localChanges), str(remoteChanges)], stdout=subprocess.PIPE, check=True) # the big merge (unchanged, unique and localChanges) # Keep in mind, that we DO NOT add remoteChanges as localChanges are preferred # over local changes for tails this is fine as everything form main git is # preferred over weblate and we would create conflicts in po files msgcat = subprocess.run(['msgcat', '-o', '-', str(unchanged), '-', str(localChanges)], input=unique.stdout, stdout=subprocess.PIPE, check=True) # filter messages actually needed (those on local and remote) msgmerge = subprocess.run(['msgmerge', '--force-po', '--quiet', '--no-fuzzy-matching', '-o', '-', '-', str(pot)], input=msgcat.stdout, stdout=subprocess.PIPE, check=True) # final merge, adds saved header subprocess.run(['msgcat', "--lang", lang, '-w', '79', '-o', str(output), '--use-first', str(header), '-'], input=msgmerge.stdout, check=True) def exists_in_tree(tree, path): length = path.rfind("/") if length != -1: try: return path in tree/path[:length] except KeyError: return False else: return path in tree def main(repopath, local, localCommit, remote): r = git.Repo(str(repopath)) msg = "merge Weblate changes using {}.\n\n".format(prog) try: local = r.refs[local] except IndexError: mcc.logInputError('{} is not a valid git reference.'.format(local)) if localCommit: try: local.commit = r.commit(localCommit) except git.BadName: mcc.logInputError('{} is not a valid git reference.'.format(local)) local.checkout() oldcommit = local.commit logger.info("local: %s", local.commit) remoteBranch = None try: remote = r.refs[remote] remoteBranch = remote remote = remote.commit except IndexError: try: remote = r.commit(remote) except git.BadName: mcc.logInputError('{} is not a valid git reference or a commit.'.format(remote)) logger.info("remote: %s", remote) if local.commit == remote: logger.info('remote and local are on the same commit, nothing to do.') return # get common ancestor base = r.merge_base(local, remote) logger.info("base: %s", base[0]) # create index to prepare the merge index = mcc.Index(git.IndexFile.from_tree(r, base, local, remote)) # is a fast-forward possible ff_possible_local = (base[0] == local.commit) # remote branch has commits on top of local local_uptodate = (base[0] == remote) # local has commits on top of remote branch try: # A set of all local modified files local_modified_files = index.local_modified_files # Process all remote modified files basediff = base[0].diff(remote) for i in base[0].diff(local.commit): path = i.b_path if i.change_type == "D": path = i.a_path local_modified_files.add(path) for i in basediff: path = i.b_path if i.change_type == "D": path = i.a_path if exists_in_tree(local.commit.tree, path): # Reset file, Weblate is not allowed to delete files. m = "{path}: reset (Weblate is not allowed to delete files).\n".format(path=path) logger.debug(m.strip()) msg += m blob = local.commit.tree/path index.updateFile(path, blob) else: index.removeFile(path) continue if not exists_in_tree(local.commit.tree, path): # File does not exists on master, that's why delete file from index # as Weblate is not allowed to create files m = "{path}: reset (Weblate is not allowed to add files).\n".format(path=path) logger.debug(m.strip()) msg += m index.removeFile(path) continue if not mcc.isWikiPo(path): # Reset file, that is not allowed to be touched by Weblate. m = "{path}: reset (Weblate is only to touch po files).\n".format(path=path) logger.debug(m.strip()) msg += m blob = local.commit.tree/path index.updateFile(path, blob) continue if i.a_path not in local_modified_files: logger.debug("%s: only remote changes - apply them.", i.a_path) # Only remote changes - apply them blob = i.b_blob index.updateFile(path, blob) continue # remote and local have modified the file m = "{path}: merging.\n".format(path=path) logger.debug(m.strip()) with temporaryDirectory(path) as tempdir: temppath = pathlib.Path(tempdir) potpath = path (temppath/'pot').write_bytes(mcc.pot(local.commit.tree/potpath)) mcc.msguniq(remote.tree/path, temppath/'remote') mcc.msguniq(local.commit.tree/i.a_path, temppath/'local') mcc.msguniq(base[0].tree/i.a_path, temppath/'base') mergePo(temppath, mcc.getLang(path), repopath/path) index.removeFile(path) index.add([path]) msg += m try: index.removeFromUnmergedBlobMap(path) except KeyError: pass # We need to write the index to file, before we can commit index.index.write() # Set the working tree to the new status of "local" and empty the index local.checkout(force=True) # We need to commit before we can test, if we simply can do a fast-forward # It seems that the diff against an index is very error-prone. index.index.commit(msg.strip(), parent_commits=(local.commit, remote)) finally: # Get rid of temporary index file, that we don't need anymore if os.path.exists(index.index.path): logger.debug("Deleting temporary index file {index_path}.".format(index_path=index.index.path)) os.unlink(index.index.path) if ff_possible_local and not local.commit.diff(remote): # We can fast-forward so let's do it local.commit = remote logger.info("Fast-forwarded local branch to {remote}.".format(remote=remote)) elif local_uptodate and not local.commit.diff(oldcommit): # We don't have to do anything. local.commit = oldcommit logger.info("Nothing to commit - local branch can be fast-forwarded from {remote}.".format(remote=remote)) # Set the working tree to the new status of "local" and empty the index local.checkout(force=True) def commandline(): import argparse import shutil global logger global prog debugPath = pathlib.Path("__debug") logging.config.fileConfig('/var/lib/weblate/config/mergeWeblateChanges.conf') logger = logging.getLogger('') logger.level = logging.INFO parser = argparse.ArgumentParser() parser.add_argument( "-v", "--verbose", action='store_true', help="verbose logging.") parser.add_argument( "-d", "--debug", action='store_true', help="keep intermediate step files in {}/.".format(debugPath)) parser.add_argument( "--local", default="master", help="name of local branch.") parser.add_argument( "--localCommit", help="reset the branch to commit first before merging. Useful for testing.") parser.add_argument( "repopath", type=pathlib.Path, help="path to the repository.") parser.add_argument( "remote", help="name of remote branch or commit id.") args = parser.parse_args() prog = parser.prog if args.verbose: logger.level = logging.DEBUG if args.debug: logger.info('Store all temporary files in %s/.', debugPath) if debugPath.exists(): shutil.rmtree(debugPath) def temporaryDirectory(path): temppath = pathlib.Path(path) if debugPath not in temppath.parents: temppath = debugPath/temppath temppath.mkdir(parents=True) return temppath else: def temporaryDirectory(path): return tempfile.TemporaryDirectory() try: main(args.repopath, args.local, args.localCommit, args.remote) except: logger.exception("-- Something unexpected happened. Giving up. --") raise else: logger.info("-- Ended successfully.") if __name__ == "__main__": commandline()