#! /usr/bin/env python """ XXX don't include in the .svncommit.tmp the last log entry, which is just the branch creation Merge the changes of a branch into the trunk. Usage: merge3.py filename.repo branch-url trunk-wc Produce a log file of commands that would merge the branch at the specified url into the working copy specified by trunk-wc. The working copy should not have local changes. This is done by analysing the repository structure stored in the given 'filename.repo', as created by dumpstructure.py. (filename.repo is automatically updated to the head by this script.) Example: merge3.py codespeak.repo \\ svn+ssh://codespeak.net/svn/pypy/branch/xyz \\ ~/pypy-dist """ # ____________________________________________________________ # # Strategy: # # - This script doesn't check anything in; it produces a log # of operations that it would like to do, which can be passed # to atomicmoves.py. # # - The goal is to avoid loosing any history, and as much as # possible keep this history in the normal svn format. For # files for which this is not possible, the trunk history # will contain one message that is itself an 'svn log' from # the history of the branch. # # - This script looks for files or whole directories modified in # the branch only. For them we write (in the log of # operations) an atomic copy from the branch to the trunk. # # - Files added and deleted should be handled correctly too - # new files in the branch are copied to the trunk, and files # deleted in the branch are also deleted in the trunk (unless # they were modified in the trunk). Corner cases cause the # script to abort for now. # # - For files modified both in the branch and the trunk: # # * if the files have the same content anyway, don't do anything # (i.e. keep the trunk history). The idea is that such files # are probably just merges from the trunk to the branch that # were never modified in an interesting way in the branch, # so it's ok to forget the branch history. This heuristic # doesn't cope well with files modified in the branch and # already merged in the trunk - you'll only see the merge # itself in the history of the trunk. But in a sense that's # not something this script has to worry about - when you did # that merge into the trunk you should have given enough # information for the trunk history to make sense already. # # * if the contents differ, this script issues an 'svn merge' # command that will change your trunk working copy. You can # see these changes with 'svn diff': they are the changes from # the branch. Note that files modified only in the branch and # not in the trunk are not 'svn merge'-ed in this way, so you # don't see them with 'svn diff'. # # * DON'T CHECK IN the modified working copy. It would bring # the trunk in an inconsistent state (it would miss all # files from the branch that are related to the changes but # were only modified in the branch). # # * a separate tool (merge3ci.py) checks these files in but # SOMEWHERE ELSE, with a check-in message that describes the # relevant history bits from the branch (the message is in # the '.merge.tmp.svncommit' files, which you can review and # edit before). "Somewhere else" is actually in the trunk # too but under a new name, ending in '.merge.tmp'. This # means that the trunk files are not overridden with partial # branch stuff yet, but the log of the trunk will still # mention these '.merge.tmp.svncommit' messages. # # - Finally, run atomicmoves.py with the merge3.log produced by # the present script. This will perform all the remove-and- # copy-from-the-branch atomically. Additionally, merge3.log # assumes that you used merge3ci.py for the problematic files # as described above, so it also contains operations to # atomically move the '.merge.tmp' files and overwrite the # real trunk files. # ____________________________________________________________ import sys, os, py, posixpath import repos from dumpstructure import loadrepo, update def rooturl(url): # "http://codespeak.net/svn/pypy/etc/etc" => "http://codespeak.net/svn" # not sure how to do this cleanly... last = None p = py.path.svnurl(url) while p.check(): last = p p = p.dirpath() if p == last: # can't go up any more break if not last: raise IOError(url) return str(last) def commonbase(node1, node2): while node1 != node2: if node1.rev > node2.rev: node1 = node1.prev else: node2 = node2.prev if node1 is None or node2 is None: return None return node1 def merge(filename, source_url, target_wc): print for wc in [target_wc]: cmdline = 'svn up %s' % wc answer = raw_input('Ok to run %r? [Y/n] ' % cmdline) if answer and not answer.upper().startswith('Y'): return os.system(cmdline) # dump a log in the format of a command file suitable for atomicmoves.py logfile = open('merge3.log', 'w', 1) print >> logfile, '# This is a log generated by merge3.py.' print >> logfile, '# It can be passed to atomicmoves.py.' print >> logfile log = DoubleFile(logfile, '# ', sys.stdout) def error(msg): print >> sys.stderr, '***', msg print >> logfile, '***', msg sys.exit(1) if source_url.endswith('/'): source_url = source_url[:-1] if target_wc.endswith('/') or target_wc.endswith('\\'): target_wc = target_wc[:-1] print >> log, 'source url:', source_url target_info = py.path.svnwc(target_wc).info() target_url = target_info.url rev = target_info.rev print >> log, 'target url:', target_url root_url = rooturl(source_url) print >> log, ' root url:', root_url assert source_url.startswith(root_url) source_path = source_url[len(root_url):] if source_path.startswith('/'): source_path = source_path[1:] assert target_url.startswith(root_url) target_path = target_url[len(root_url):] if target_path.startswith('/'): target_path = target_path[1:] print >> log, ' revision:', rev repo = loadrepo(filename) update(repo, filename, rev=rev) if repo.getrev() != rev: error("The dump file is more recent than the working copy (svn up)") source_node = repo.getpath(source_path, rev) target_node = repo.getpath(target_path, rev) base_node = commonbase(source_node, target_node) if base_node is None: error("The two URLs have no common base revision") print >> log, 'common base rev:', base_node.rev print >> logfile print >> logfile, 'root %s HEAD' % (root_url,) print >> logfile conflicts = [] conflictwarnings = [] def log_delete(path): assert not path.startswith('/') node = target_node.getpath(path) print >> logfile, 'rm %s %s' % (node.__class__.__name__.lower(), posixpath.join(target_path, path)) def log_copy(path): assert not path.startswith('/') node = source_node.getpath(path) print >> logfile, 'cp %s %s %s' % (node.__class__.__name__.lower(), posixpath.join(source_path, path), posixpath.join(target_path, path)) def log_copy_from_tmp(path): assert not path.startswith('/') node = target_node.getpath(path) dst = posixpath.join(target_path, path) tmp = dst + '.merge.tmp' print >> logfile, 'cp %s %s %s' % (node.__class__.__name__.lower(), tmp, dst) print >> logfile, 'rm %s %s' % (node.__class__.__name__.lower(), tmp) def walk(relpath, nbase, nbranch, ntrunk): # for naming sanity we assume that the source is the branch # and the target is the trunk if nbase == nbranch: return # no change in the branch if nbranch == ntrunk: return # already in sync if nbase == ntrunk: # must replace the trunk node with the branch node log_delete(relpath) log_copy(relpath) return # otherwise, we need a 3-way merge if not (nbase.__class__ == nbranch.__class__ == ntrunk.__class__): error("path %r was a file and is now a dir or vice-versa" % (relpath,)) if isinstance(nbase, repos.File): # are the two files' content equal? print >> log, '[ ] common base: %s@%d' % (relpath, nbase.rev) print >> log, '[ ] in the trunk: %s@%d' % (relpath, ntrunk.rev) print >> log, '[ ] in the branch: %s@%d' % (relpath, nbranch.rev) cmdline = 'svn diff %s@%d %s@%d' % (getsourceurl(relpath), rev, gettargeturl(relpath), rev) print >> log, '[*]', cmdline g = os.popen(cmdline, 'r') differences = g.read() g.close() if not differences.strip(): print >> log, ' equal (ignoring history)' return else: print >> log, ' DIFFERENT' if not isinstance(nbase, repos.Dir): conflicts.append(relpath) # don't try to merge files for now return # merge directories file-by-file, based on file name for key, value in nbranch.getentries(): subpath = posixpath.join(relpath, key) if key in ntrunk.entries: # merge the two files/subdirs subbase = commonbase(value, ntrunk.entries[key]) if subbase is None: conflicts.append(subpath) else: walk(subpath, subbase, value, ntrunk.entries[key]) elif key in nbase.entries: # file/subdir was deleted from the trunk # that's fine unless it was modified in the branch if nbase.entries[key] != value: conflictwarnings.append(subpath) log_copy(subpath) else: # file/subdir was added in the branch log_copy(subpath) for key, value in ntrunk.getentries(): if key not in nbranch.entries: subpath = posixpath.join(relpath, key) if key in nbase.entries: # file/subdir was deleted in the branch # check if it was modified in the trunk if nbase.entries[key] != value: conflictwarnings.append(subpath) else: log_delete(subpath) else: # file/subdir was added in the trunk, nothing to do pass def getwcpath(relpath): return os.path.join(target_wc, *relpath.split('/')) def getsourceurl(relpath): return posixpath.join(source_url, relpath) def gettargeturl(relpath): return posixpath.join(target_url, relpath) walk('', base_node, source_node, target_node) print >> log, ('*** The following files/dirs have been modified both' ' in the source and target:') print >> log merge_tmp_needed = False if conflicts: for relpath in conflicts: targetfilepath = getwcpath(relpath) sourcefileurl = getsourceurl(relpath) print >> log, targetfilepath cmd = 'svn merge --non-interactive -r %d:%d %s %r' % ( base_node.rev, rev, sourcefileurl, targetfilepath) print >> log, '[*]', cmd os.system(cmd) if py.path.svnwc(targetfilepath).status().unchanged: print >> log, 'fully merged, no changes left.' continue merge_tmp_needed = True cmd = 'svn log -r %d:%d %s' % ( rev, base_node.rev+1, sourcefileurl) print >> log, '[*]', cmd g = os.popen(cmd, 'r') logmessages = g.read() g.close() g = open(targetfilepath + '.merge.tmp.svncommit', 'w') print >> g, 'merging of %s' % (sourcefileurl,) print >> g, 'revisions %d to %d:' % (base_node.rev, rev) print >> g for line in logmessages.splitlines(): print >> g, ' ' + line g.close() # to be used after the user resolved any conflicts and checked in log_delete(relpath) log_copy_from_tmp(relpath) else: print >> log, "(none)" print >> log if conflictwarnings: print >> log, '*** The following files/dirs have been deleted on one side and' print >> log, '*** modified on the other; we will keep the modified version:' print >> log for relpath in conflictwarnings: print >> log, gettargeturl(relpath) print >> log print >> log, '***' print >> log if merge_tmp_needed: print >> log, """ *** What you need to do next: The working copy of the branch was modified with 'svn merge' for the conflicting files. * Review in your working copy the changes and conflicts that would come from the branch. DON'T CHECK THEM IN! * Review the .merge.tmp.svncommit messages. * Check the changes in using 'merge3ci.py'. This will create temporary files with the .merge.tmp extension in your trunk. * Finally, run 'atomicmoves.py merge3.log'. This will move selected files and directories from the branch to the trunk, and from the .merge.tmp files back to the normal files. """ else: print >> log, """ *** You can now run 'atomicmoves.py merge3.log'. This will move selected files and directories from the branch to the trunk and the merge will be done. """ class DoubleFile: def __init__(self, f1, prefix1, f2): self.f1 = f1 self.prefix1 = prefix1 self.curprefix = prefix1 self.f2 = f2 def write(self, data): if not data: return self.f2.write(data) self.f1.write(self.curprefix + data[:-1].replace('\n', '\n'+self.prefix1) + data[-1]) if data[-1] == '\n': self.curprefix = self.prefix1 else: self.curprefix = '' def writelines(self, data): self.write(''.join(data)) if __name__ == '__main__': if len(sys.argv) != 4: print __doc__ sys.exit(2) merge(*sys.argv[1:])