#! /usr/bin/env python """ Usage: merge.py filename.repo source-url target-wc Merge the directory specified by source-url into the working copy specified by target-wc. Typically, source-url is a branch and target-wc is the trunk from which it was forked. 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. Example: merge.py codespeak.repo http://codespeak.net/svn/pypy/branch/xyz ~/pypy-dist """ 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 cmdline = 'svn up %s' % target_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('merge.log', 'w', 1) print >> logfile, '# This is a log generated by merge.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 %d' % (root_url, rev) print >> logfile conflicts = [] conflictwarnings = [] to_delete = [] to_copy = [] two_steps_needed = [] commands = [] 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)) to_delete.append(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)) to_copy.append(path) 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) two_steps_needed.append(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) if conflicts: print >> log, ('*** The following files/dirs have been modified both' ' in the source and target:') print >> log for relpath in conflicts: print >> log, gettargeturl(relpath) print >> log print >> log, '*** Resolve these conflicts and try again.' print >> log, "Conflicts are resolved by svn-copying or svn-merging files and" print >> log, "directories between trunk and branch until they are identical." print >> log print >> log, "%r contains the commands that would have been" % logfile.name print >> log, "performed if we ignored these conflicts." sys.exit(1) 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, '***' def nextcmd(cmd): print cmd commands.append(cmd) def run_commands(): for cmd in commands: print '*', cmd os.system(cmd) if not to_delete and not to_copy: print >> log, "*** Nothing to do." return print '='*60 checkin = 'svn commit %s' % target_wc print for relpath in to_delete: nextcmd('svn rm %s' % getwcpath(relpath)) if two_steps_needed: nextcmd(checkin) for relpath in to_copy: nextcmd('svn cp -r%d %s %s' % (rev, getsourceurl(relpath), getwcpath(relpath))) nextcmd(checkin) print if two_steps_needed: print "*** This merge requires two check-ins: deleting old files," print "*** and copying their newest version back." print "*** If you answer W below you can check in manually and run" print "*** the script again for the rest of the commands." print logfile.flush() print '*** Note that %r can be used with atomicmoves.py to' % logfile.name print '*** perform the check-in in a single direct server connexion.' print print '*** Run the above commands?' input = raw_input('[A]ll / [W]ithout commit / [C]ancel: ') if input.upper().startswith('A'): run_commands() elif input.upper().startswith('W'): del commands[commands.index(checkin):] run_commands() 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:])