source: tailor/vcpx/cvs.py @ 259

Revision 259, 14.5 KB checked in by lele@…, 8 years ago (diff)

Revisited zooko's ReopenableNamedTemporaryFile? patch

Line 
1#! /usr/bin/python
2# -*- mode: python; coding: utf-8 -*-
3# :Progetto: vcpx -- Pure CVS solution
4# :Creato:   dom 11 lug 2004 01:59:36 CEST
5# :Autore:   Lele Gaifax <lele@nautilus.homeip.net>
6#
7
8"""
9Given `cvsps` shortcomings, this backend uses CVS only.
10"""
11
12__docformat__ = 'reStructuredText'
13
14from shwrap import SystemCommand, ReopenableNamedTemporaryFile
15from cvsps import CvspsWorkingDir
16from source import GetUpstreamChangesetsFailure
17
18class EmptyRepositoriesFoolsMe(Exception):
19    "Cannot handle empty repositories. Maybe wrong module/repository?"
20   
21    # This is the exception raised when we try to tailor an empty CVS
22    # repository. This is more a shortcoming of tailor, rather than a
23    # real problem with those repositories.
24   
25    pass
26
27def compare_cvs_revs(rev1, rev2):
28    """Compare two CVS revision numerically, not alphabetically."""
29
30    if not rev1: rev1 = '0'
31    if not rev2: rev2 = '0'
32
33    r1 = [int(n) for n in rev1.split('.')]
34    r2 = [int(n) for n in rev2.split('.')]
35   
36    return cmp(r1, r2)
37
38
39class CvsLog(SystemCommand):
40    COMMAND = "TZ=UTC cvs -f -d%(repository)s rlog -N -S %(branch)s %(since)s %(module)s > %(tempfilename)s 2>&1"
41       
42    def __call__(self, output=None, dry_run=False, **kwargs):
43        since = kwargs.get('since')
44        if since:
45            kwargs['since'] = "-d'%s UTC<'" % since
46        else:
47            kwargs['since'] = ''
48
49        branch = kwargs.get('branch') or 'HEAD'
50        kwargs['branch'] = "-r:%s" % branch
51       
52       
53        self.rontf = ReopenableNamedTemporaryFile('cvs', 'tailor')
54        logfn = kwargs['tempfilename'] = self.rontf.name
55       
56        SystemCommand.__call__(self, output=False, dry_run=dry_run, **kwargs)
57
58        return open(logfn)
59
60
61def changesets_from_cvslog(log, module):
62    """
63    Parse CVS log.
64    """
65
66    from datetime import timedelta
67
68    collected = ChangeSetCollector(log, module)
69    collapsed = []
70
71    threshold = timedelta(seconds=180)
72    last = None
73   
74    for cs in collected:
75        if not last:
76            last = cs
77            collapsed.append(cs)
78        else:
79            if last.author == cs.author and \
80               last.log == cs.log and \
81               abs(last.date - cs.date) < threshold:
82                last.entries.extend(cs.entries)
83            else:
84                last = cs
85                collapsed.append(cs)
86
87    return collapsed
88
89       
90class ChangeSetCollector(object):
91    """Collector of the applied change sets."""
92   
93    def __init__(self, log, module):
94        """
95        Initialize a ChangeSetCollector instance.
96
97        Loop over the modified entries and collect their logs.
98        """
99
100        self.changesets = {}
101        """The dictionary mapping (date, author, log) to each entry."""
102
103        self.log = log
104        """The log to be parsed."""
105       
106        self.module = module
107        """The CVS module name."""
108       
109        self.__parseCvsLog()
110
111    def __iter__(self):
112        keys = self.changesets.keys()
113        keys.sort()
114        return iter([self.changesets[k] for k in keys])
115   
116    def __getGlobalRevision(self, timestamp, author, changelog):
117        """
118        CVS does not have the notion of a repository-wide revision number,
119        since it tracks just single files.
120
121        Here we could "count" the grouped changesets ala `cvsps`,
122        but that's tricky because of branches.  Since right now there
123        is nothing that depends on this being a number, not to mention
124        a *serial* number, simply emit a (hopefully) unique signature...
125        """
126
127        # NB: the getUpstreamChangesets() below depends on this format
128
129        return "%s by %s" % (timestamp, author)
130
131    def __collect(self, timestamp, author, changelog, entry, revision):
132        """Register a change set about an entry."""
133
134        from changes import Changeset
135       
136        key = (timestamp, author, changelog)
137        if self.changesets.has_key(key):
138            return self.changesets[key].addEntry(entry, revision)
139        else:
140            cs = Changeset(self.__getGlobalRevision(timestamp,
141                                                    author,
142                                                    changelog),
143                           timestamp, author, changelog)
144            self.changesets[key] = cs
145            return cs.addEntry(entry, revision)
146
147    def __readline(self):
148        """
149        Read a line from the log, intercepting the directory being listed.
150
151        This is used to determine the pathname of each entry, relative to
152        the root of the working copy.
153        """
154
155        l = self.log.readline()
156        while l.startswith('cvs rlog: Logging '):
157            currentdir = l[18:-1]
158            # strip away first component, the name of the product
159            if '/' in currentdir:
160                assert currentdir.startswith(self.module), \
161                       "Directory %s does not start with %s" % (currentdir,
162                                                                self.module)
163                self.__currentdir = currentdir[len(self.module)+1:]
164            else:
165                self.__currentdir = ''
166            l = self.log.readline()
167
168        return l
169   
170    def __parseRevision(self, entry):
171        """
172        Parse a single revision log, extracting the needed information.
173
174        Return None when there are no more logs to be parsed,
175        otherwise a tuple with the relevant data.
176        """
177
178        from datetime import datetime
179       
180        revision = self.__readline()
181        if not revision or not revision.startswith('revision '):
182            return None
183        rev = revision[9:-1]
184
185        infoline = self.__readline()
186
187        info = infoline.split(';')
188
189        assert info[0][:6] == 'date: ', infoline
190
191        # 2004-04-19 14:45:42 +0000, the timezone may be missing
192        dateparts = info[0][6:].split(' ')
193        assert len(dateparts) >= 2, `dateparts`
194       
195        day = dateparts[0]
196        time = dateparts[1]
197        y,m,d = map(int, day.split(day[4]))
198        hh,mm,ss = map(int, time.split(':'))
199        date = datetime(y,m,d,hh,mm,ss)
200
201        assert info[1].strip()[:8] == 'author: ', infoline
202
203        author = info[1].strip()[8:]
204
205        assert info[2].strip()[:7] == 'state: ', infoline
206
207        state = info[2].strip()[7:]
208       
209        # Fourth element, if present and like "lines +x -y", indicates
210        # this is a change to an existing file. Otherwise its a new
211        # one.
212
213        newentry = not info[3].strip().startswith('lines: ')
214       
215        # The next line may be either the first of the changelog or a
216        # continuation (?) of the preceeding info line with the
217        # "branches"
218
219        l = self.__readline()
220        if l.startswith('branches: ') and l.endswith(';\n'):
221            infoline = infoline[:-1] + ';' + l
222            # read the effective first line of log
223            l = self.__readline()
224           
225        mesg = []
226        while (l <> '----------------------------\n' and
227               l <> '=============================================================================\n'):
228            mesg.append(l[:-1])
229            l = self.__readline()
230
231        if len(mesg)==1 and mesg[0] == '*** empty log message ***':
232            changelog = ''
233        else:
234            changelog = '\n'.join(mesg)
235           
236        return (date, author, changelog, entry, rev, state, newentry)
237   
238    def __parseCvsLog(self):
239        """Parse a complete CVS log."""
240
241        from os.path import split, join
242
243        self.__currentdir = None
244       
245        while 1:
246            l = self.__readline()
247            while l and not l.startswith('RCS file: '):
248                l = self.__readline()
249           
250            if not l.startswith('RCS file: '):
251                break
252
253            assert self.__currentdir is not None, \
254                   "Missed 'cvs rlog: Logging XX' line"
255           
256            entry = join(self.__currentdir, split(l[10:-1])[1][:-2])
257           
258            l = self.__readline()
259            while l and l <> '----------------------------\n':
260                l = self.__readline()
261               
262            cs = self.__parseRevision(entry)
263            while cs:
264                date,author,changelog,e,rev,state,newentry = cs
265
266                last = self.__collect(date, author, changelog, e, rev)
267                if state == 'dead':
268                    last.action_kind = last.DELETED
269                elif newentry:
270                    last.action_kind = last.ADDED
271                else:
272                    last.action_kind = last.UPDATED
273               
274                cs = self.__parseRevision(entry)
275       
276
277class CvsWorkingDir(CvspsWorkingDir):
278    """
279    Reimplement the mechanism used to get a *changeset* view of the
280    CVS commits.
281    """
282   
283    def getUpstreamChangesets(self, root, repository, module, sincerev=None):
284        from os.path import join, exists
285        from datetime import timedelta
286       
287        entries = CvsEntries(root)
288        youngest_entry = entries.getYoungestEntry()
289        if youngest_entry is None:
290            raise EmptyRepositoriesFoolsMe("The working copy '%s' of the CVS repository seems empty, don't know how to deal with that." % root)
291       
292        if not sincerev:
293            # We are bootstrapping, trying to collimate the
294            # actual revision on disk with the changesets.
295            youngest_ts = youngest_entry.timestamp
296            youngest_ts -= timedelta(days=15)
297            since = youngest_ts.isoformat(sep=' ')
298        else:
299            # Assume this is from __getGlobalRevision()
300            since,author = sincerev.split(' by ')
301
302        branch = ''
303        fname = join(root, 'CVS', 'Tag')
304        if exists(fname):
305            tag = open(fname).read()
306            if tag[0] in 'NT':
307                branch=tag[1:-1]
308
309        cvslog = CvsLog(working_dir=root)
310       
311        changesets = []
312        log = cvslog(output=True, since=since, branch=branch,
313                     repository=repository, module=module)
314
315        if cvslog.exit_status:
316            raise GetUpstreamChangesetsFailure("'cvs log' on %r returned status %d" % (root, cvslog.exit_status))
317               
318        for cs in changesets_from_cvslog(log, module):
319            for e in cs.entries:
320                # If the entry is not already there, and, for whatever
321                # reason (most probably, manually tweaked CVS
322                # repository), from the log we desumed it's an update,
323                # consider it as a NEW entry instead.
324               
325                if (e.action_kind == e.UPDATED and
326                    entries.getFileInfo(e.name) is None):
327                    e.action_kind = e.ADDED
328                   
329            changesets.append(cs)
330
331        return changesets
332   
333
334class CvsEntry(object):
335    """Collect the info about a file in a CVS working dir."""
336   
337    __slots__ = ('filename', 'cvs_version', 'timestamp', 'cvs_tag')
338
339    def __init__(self, entry):
340        """Initialize a CvsEntry."""
341
342        from datetime import datetime
343        from time import strptime
344       
345        dummy, fn, rev, ts, dummy, tag = entry.split('/')
346
347        if ts.startswith('Result of merge+'):
348            ts = ts[16:]
349           
350        self.filename = fn
351        self.cvs_version = rev
352        y,m,d,hh,mm,ss,d1,d2,d3 = strptime(ts, "%a %b %d %H:%M:%S %Y")
353        self.timestamp = datetime(y,m,d,hh,mm,ss)
354        self.cvs_tag = tag
355
356    def __str__(self):
357        return "CvsEntry('%s', '%s', '%s')" % (self.filename,
358                                               self.cvs_version,
359                                               self.cvs_tag)
360
361
362class CvsEntries(object):
363    """Collection of CvsEntry."""
364
365    __slots__ = ('files', 'directories', 'deleted')
366   
367    def __init__(self, root):
368        """Parse CVS/Entries file.
369
370           Walk down the working directory, collecting info from each
371           CVS/Entries found."""
372
373        from os.path import join, exists, isdir
374        from os import listdir
375       
376        self.files = {}
377        """Dict of `CvsEntry`, keyed on each file under revision control."""
378       
379        self.directories = {}
380        """Dict of `CvsEntries`, keyed on subdirectories under revision
381           control."""
382
383        self.deleted = False
384        """Flag to denote that this directory was removed."""
385       
386        entries = join(root, 'CVS', 'Entries')
387        if exists(entries):
388            for entry in open(entries).readlines():
389                entry = entry[:-1]
390
391                if entry.startswith('/'):
392                    e = CvsEntry(entry)
393                    if file and e.filename==file:
394                        return e
395                    else:
396                        self.files[e.filename] = e
397                elif entry.startswith('D/'):
398                    d = entry.split('/')[1]
399                    subdir = CvsEntries(join(root, d))
400                    self.directories[d] = subdir
401                elif entry == 'D':
402                    self.deleted = True 
403
404            # Sometimes the Entries file does not contain the directories:
405            # crawl the current directory looking for missing ones.
406
407            for entry in listdir(root):
408                if entry == '.svn':
409                    continue               
410                dir = join(root, entry)
411                if (isdir(dir) and exists(join(dir, 'CVS', 'Entries'))
412                    and not self.directories.has_key(entry)):
413                    self.directories[entry] = CvsEntries(dir)
414                   
415            if self.deleted:
416                self.deleted = not self.files and not self.directories
417           
418    def __str__(self):
419        return "CvsEntries(%d files, %d subdirectories)" % (
420            len(self.files), len(self.directories))
421
422    def getFileInfo(self, fpath):
423        """Fetch the info about a path, if known.  Otherwise return None."""
424
425        try:
426            if '/' in fpath:
427                dir,rest = fpath.split('/', 1)
428                return self.directories[dir].getFileInfo(rest)
429            else:
430                return self.files[fpath]
431        except KeyError:
432            return None
433
434    def getYoungestEntry(self):
435        """Find and return the most recently changed entry."""
436       
437        latest = None
438       
439        for e in self.files.values():
440            if not latest or e.timestamp > latest.timestamp:
441                latest = e
442
443        for d in self.directories.values():
444            e = d.getYoungestEntry()
445
446            # skip if there are no entries in the directory
447            if not e:
448                continue
449           
450            if not latest or e.timestamp > latest.timestamp:
451                latest = e
452
453        return latest
Note: See TracBrowser for help on using the repository browser.