source: mergebot/trunk/mergebot/svn.py

Last change on this file was 62, checked in by retracile, 14 years ago

Mergebot: handle property merge conflicts, and add tests for it

File size: 8.7 KB
Line 
1#!/usr/bin/python
2"""
3Encapsulate logical Subversion operations so the various MergeBot actors can
4operate at a higher level of abstraction.
5"""
6
7import os
8import time
9import re
10import subprocess
11
12def shell_quote(string):
13    """Given a string, escape the characters interpretted by the shell."""
14    for char in ["\\", "\"", "$"]:
15        string = string.replace(char, "\\%s" % (char, ))
16    return '"%s"' % (string, )
17
18
19class SvnLib(object):
20    """A library to provide a higher-level set of subversion operations."""
21    def __init__(self):
22        self.svn_version = self.get_svn_version()
23
24    def get_svn_version(self):
25        svn = subprocess.Popen(['svn', '--version', '--quiet'],
26                                stdout=subprocess.PIPE, stderr=subprocess.PIPE)
27        version_string, _stderr = svn.communicate()
28        return [int(x) for x in version_string.split('.')]
29       
30    def logcmd(self, cmd, logfile):
31        """Log the cmd string, then execute it, appending its stdout and stderr
32        to logfile."""
33        log = open(logfile, "a")
34        log.write("%s: %s\n" % (time.asctime(), cmd))
35        task = subprocess.Popen(cmd, stdout=log, stderr=log)
36        task.communicate()
37        return task.wait()
38
39    def get_rev_from_log(self, logentry):
40        """Given a log entry split out of svn log, return its revision number"""
41        return int(logentry.split()[0][1:])
42
43    def does_url_exist_14(self, url):
44        """Given a subversion url return true if it exists, false otherwise."""
45        return not subprocess.call(['svn', 'log', '--limit=1',
46                                    '--non-interactive', url],
47                        stdout=open('/dev/null', 'w'),
48                        stderr=open('/dev/null', 'w'))
49
50    def does_url_exist_15(self, url):
51        """Given a subversion url return true if it exists, false otherwise."""
52        return not subprocess.call(['svn', 'ls', '--depth', 'empty',
53                                    '--non-interactive', url],
54                        stdout=open('/dev/null', 'w'),
55                        stderr=open('/dev/null', 'w'))
56
57    def does_url_exist(self, url):
58        if self.svn_version[:2] == [1,4]:
59            return self.does_url_exist_14(url)
60        return self.does_url_exist_15(url)
61
62    def get_branch_info(self, url, logfile):
63        """Given a subversion url and a logfile, return (start_revision,
64        end_revision) or None if it does not exist."""
65        svncmd = subprocess.Popen(['svn', 'log', '--stop-on-copy', '--non-interactive', url], stdout=subprocess.PIPE, stderr=open(logfile, 'a'))
66        branchlog, stderr = svncmd.communicate()
67        returnval = svncmd.wait()
68        if returnval:
69            # This branch apparently doesn't exist
70            return None
71        logs = branchlog.split("-"*72 + "\n")
72        # If there have been no commits on the branch since it was created,
73        # there will only be one revision listed.... but the log will split
74        # into 3 parts.
75        endrev = self.get_rev_from_log(logs[1])
76        startrev = self.get_rev_from_log(logs[-2])
77        return (startrev, endrev)
78
79    def create_branch(self, from_url, to_url, commit_message, logfile):
80        """Create a branch copying from_url to to_url.  Commit as mergebot, and
81        use the provided commit message."""
82        svncmd = ['svn', 'copy', '--username=mergebot', '--password=mergebot',
83            '-m', shell_quote(commit_message), from_url, to_url]
84        return self.logcmd(svncmd, logfile)
85
86    def delete_branch(self, url, commit_message, logfile):
87        """This will generate a new revision.  Return the revision number, or
88        -1 on failure.
89        Assumes that the url exists.  You should call get_branch_info() to
90        determine that first"""
91        svncmd = "svn rm --no-auth-cache " \
92            "--username=mergebot --password=mergebot " \
93            "-m %s %s 2>>%s" % (shell_quote(commit_message), url, logfile)
94        return self._svn_new_rev_command(svncmd)
95
96    def checkout(self, from_url, workingdir, logfile):
97        """Checkout from the given url into workingdir"""
98        return os.system("svn checkout %s %s >>%s 2>&1" % (from_url, workingdir,
99            logfile))
100
101    def merge(self, from_url, workingdir, revision_range, logfile):
102        if self.svn_version[:2] == [1,4]:
103            return self.merge14(from_url, workingdir, revision_range, logfile)
104        return self.merge16(from_url, workingdir, revision_range, logfile)
105
106    def merge14(self, from_url, workingdir, revision_range, logfile):
107        """Returns a list (status, filename) tuples"""
108        # There are a couple of different 'Skipped' messages.
109        skipped_regex = re.compile("Skipped.* '(.*)'", re.M)
110        start_rev, end_rev = revision_range
111        pipe = os.popen("cd %s && svn merge --revision %s:%s %s . 2>>%s" % \
112            (workingdir, start_rev, end_rev, from_url, logfile))
113        output = pipe.readlines()
114        # FIXME: check pipe.close for errors
115        results = []
116        for line in output:
117            if line.startswith("Skipped"):
118                # This kind of conflict requires special handling.
119                filename = skipped_regex.findall(line)[0]
120                status = "C"
121            else:
122                assert line[4] == ' ', "Unexpected output from svn merge " \
123                    "operation; the 5th character should always be a space." \
124                    "  Output was %r." % line
125                filename = line[5:-1] # (strip trailing newline)
126                status = line[:4].rstrip()
127            results.append((status, filename))
128        return results
129
130    def merge16(self, from_url, workingdir, revision_range, logfile):
131        """Returns a list (status, filename) tuples"""
132        # There are a couple of different 'Skipped' messages.
133        skipped_regex = re.compile("Skipped.* '(.*)'", re.M)
134        start_rev, end_rev = revision_range
135        pipe = subprocess.Popen(['svn', 'merge', '--non-interactive', '--revision', '%s:%s' % (start_rev, end_rev), from_url, '.'],
136            cwd = workingdir,
137            stdout = subprocess.PIPE,
138            stderr = open(logfile, 'a')
139        )
140        output, _stderr = pipe.communicate()
141        # FIXME: check pipe.close for errors
142        # errorcode = pipe.wait()
143        results = []
144        for line in output.split('\n'):
145            if line == '':
146                continue # ignore blank lines
147            elif line.startswith("Skipped"):
148                # This kind of conflict requires special handling.
149                filename = skipped_regex.findall(line)[0]
150                status = "C"
151            elif line.startswith("--- Merging r"):
152                continue # ignore the line
153            elif line == 'Summary of conflicts:':
154                continue # ignore the line
155            elif line.startswith('  Text conflicts:'):
156                continue # ignore the line
157            elif line.startswith('  Tree conflicts:'):
158                continue # ignore the line
159            elif line.startswith('  Property conflicts:'):
160                continue # ignore the line
161            else:
162                assert len(line) > 4 and line[4] == ' ', "Unexpected output " \
163                    "from svn merge operation; the 5th character should " \
164                    "always be a space.  Invalid output line was %r.\nFull output was: %r." % (line, output)
165                filename = line[5:]
166                status = line[:4].rstrip()
167            results.append((status, filename))
168        return results
169
170    def conflicts_from_merge_results(self, results):
171        """Given the output from merge, return a list of files that had
172        conflicts."""
173        conflicts = [filename for status, filename in results if 'C' in status]
174        return conflicts
175
176    def commit(self, workingdir, commit_message, logfile):
177        """Returns newly committed revision number, or None if there was
178        nothing to commit.  -1 on error."""
179        svncmd = "cd %s && svn commit --no-auth-cache --username=mergebot " \
180            "--password=mergebot -m %s 2>>%s" % (workingdir,
181                shell_quote(commit_message), logfile)
182        return self._svn_new_rev_command(svncmd)
183
184    def _svn_new_rev_command(self, svncmd):
185        """Given an svn command that results in a new revision, return the
186        revision number, or -1 on error."""
187        pipe = os.popen(svncmd)
188        output = pipe.read()
189        retval = pipe.close()
190        if retval:
191            new_revision = -1
192        else:
193            new_revisions = re.compile("Committed revision ([0-9]+)\\.",
194                re.M).findall(output)
195            if new_revisions:
196                new_revision = new_revisions[0]
197            else:
198                new_revision = None
199        return new_revision
200
201# vim:foldcolumn=4 foldmethod=indent
202# vim:tabstop=4 shiftwidth=4 softtabstop=4 expandtab
Note: See TracBrowser for help on using the repository browser.