1# monotone.py - monotone support for the convert extension
2#
3#  Copyright 2008, 2009 Mikkel Fahnoe Jorgensen <mikkel@dvide.com> and
4#  others
5#
6# This software may be used and distributed according to the terms of the
7# GNU General Public License version 2 or any later version.
8from __future__ import absolute_import
9
10import os
11import re
12
13from mercurial.i18n import _
14from mercurial.pycompat import open
15from mercurial import (
16    error,
17    pycompat,
18)
19from mercurial.utils import dateutil
20
21from . import common
22
23
24class monotone_source(common.converter_source, common.commandline):
25    def __init__(self, ui, repotype, path=None, revs=None):
26        common.converter_source.__init__(self, ui, repotype, path, revs)
27        if revs and len(revs) > 1:
28            raise error.Abort(
29                _(
30                    b'monotone source does not support specifying '
31                    b'multiple revs'
32                )
33            )
34        common.commandline.__init__(self, ui, b'mtn')
35
36        self.ui = ui
37        self.path = path
38        self.automatestdio = False
39        self.revs = revs
40
41        norepo = common.NoRepo(
42            _(b"%s does not look like a monotone repository") % path
43        )
44        if not os.path.exists(os.path.join(path, b'_MTN')):
45            # Could be a monotone repository (SQLite db file)
46            try:
47                f = open(path, b'rb')
48                header = f.read(16)
49                f.close()
50            except IOError:
51                header = b''
52            if header != b'SQLite format 3\x00':
53                raise norepo
54
55        # regular expressions for parsing monotone output
56        space = br'\s*'
57        name = br'\s+"((?:\\"|[^"])*)"\s*'
58        value = name
59        revision = br'\s+\[(\w+)\]\s*'
60        lines = br'(?:.|\n)+'
61
62        self.dir_re = re.compile(space + b"dir" + name)
63        self.file_re = re.compile(
64            space + b"file" + name + b"content" + revision
65        )
66        self.add_file_re = re.compile(
67            space + b"add_file" + name + b"content" + revision
68        )
69        self.patch_re = re.compile(
70            space + b"patch" + name + b"from" + revision + b"to" + revision
71        )
72        self.rename_re = re.compile(space + b"rename" + name + b"to" + name)
73        self.delete_re = re.compile(space + b"delete" + name)
74        self.tag_re = re.compile(space + b"tag" + name + b"revision" + revision)
75        self.cert_re = re.compile(
76            lines + space + b"name" + name + b"value" + value
77        )
78
79        attr = space + b"file" + lines + space + b"attr" + space
80        self.attr_execute_re = re.compile(
81            attr + b'"mtn:execute"' + space + b'"true"'
82        )
83
84        # cached data
85        self.manifest_rev = None
86        self.manifest = None
87        self.files = None
88        self.dirs = None
89
90        common.checktool(b'mtn', abort=False)
91
92    def mtnrun(self, *args, **kwargs):
93        if self.automatestdio:
94            return self.mtnrunstdio(*args, **kwargs)
95        else:
96            return self.mtnrunsingle(*args, **kwargs)
97
98    def mtnrunsingle(self, *args, **kwargs):
99        kwargs['d'] = self.path
100        return self.run0(b'automate', *args, **kwargs)
101
102    def mtnrunstdio(self, *args, **kwargs):
103        # Prepare the command in automate stdio format
104        kwargs = pycompat.byteskwargs(kwargs)
105        command = []
106        for k, v in pycompat.iteritems(kwargs):
107            command.append(b"%d:%s" % (len(k), k))
108            if v:
109                command.append(b"%d:%s" % (len(v), v))
110        if command:
111            command.insert(0, b'o')
112            command.append(b'e')
113
114        command.append(b'l')
115        for arg in args:
116            command.append(b"%d:%s" % (len(arg), arg))
117        command.append(b'e')
118        command = b''.join(command)
119
120        self.ui.debug(b"mtn: sending '%s'\n" % command)
121        self.mtnwritefp.write(command)
122        self.mtnwritefp.flush()
123
124        return self.mtnstdioreadcommandoutput(command)
125
126    def mtnstdioreadpacket(self):
127        read = None
128        commandnbr = b''
129        while read != b':':
130            read = self.mtnreadfp.read(1)
131            if not read:
132                raise error.Abort(_(b'bad mtn packet - no end of commandnbr'))
133            commandnbr += read
134        commandnbr = commandnbr[:-1]
135
136        stream = self.mtnreadfp.read(1)
137        if stream not in b'mewptl':
138            raise error.Abort(
139                _(b'bad mtn packet - bad stream type %s') % stream
140            )
141
142        read = self.mtnreadfp.read(1)
143        if read != b':':
144            raise error.Abort(_(b'bad mtn packet - no divider before size'))
145
146        read = None
147        lengthstr = b''
148        while read != b':':
149            read = self.mtnreadfp.read(1)
150            if not read:
151                raise error.Abort(_(b'bad mtn packet - no end of packet size'))
152            lengthstr += read
153        try:
154            length = pycompat.long(lengthstr[:-1])
155        except TypeError:
156            raise error.Abort(
157                _(b'bad mtn packet - bad packet size %s') % lengthstr
158            )
159
160        read = self.mtnreadfp.read(length)
161        if len(read) != length:
162            raise error.Abort(
163                _(
164                    b"bad mtn packet - unable to read full packet "
165                    b"read %s of %s"
166                )
167                % (len(read), length)
168            )
169
170        return (commandnbr, stream, length, read)
171
172    def mtnstdioreadcommandoutput(self, command):
173        retval = []
174        while True:
175            commandnbr, stream, length, output = self.mtnstdioreadpacket()
176            self.ui.debug(
177                b'mtn: read packet %s:%s:%d\n' % (commandnbr, stream, length)
178            )
179
180            if stream == b'l':
181                # End of command
182                if output != b'0':
183                    raise error.Abort(
184                        _(b"mtn command '%s' returned %s") % (command, output)
185                    )
186                break
187            elif stream in b'ew':
188                # Error, warning output
189                self.ui.warn(_(b'%s error:\n') % self.command)
190                self.ui.warn(output)
191            elif stream == b'p':
192                # Progress messages
193                self.ui.debug(b'mtn: ' + output)
194            elif stream == b'm':
195                # Main stream - command output
196                retval.append(output)
197
198        return b''.join(retval)
199
200    def mtnloadmanifest(self, rev):
201        if self.manifest_rev == rev:
202            return
203        self.manifest = self.mtnrun(b"get_manifest_of", rev).split(b"\n\n")
204        self.manifest_rev = rev
205        self.files = {}
206        self.dirs = {}
207
208        for e in self.manifest:
209            m = self.file_re.match(e)
210            if m:
211                attr = b""
212                name = m.group(1)
213                node = m.group(2)
214                if self.attr_execute_re.match(e):
215                    attr += b"x"
216                self.files[name] = (node, attr)
217            m = self.dir_re.match(e)
218            if m:
219                self.dirs[m.group(1)] = True
220
221    def mtnisfile(self, name, rev):
222        # a non-file could be a directory or a deleted or renamed file
223        self.mtnloadmanifest(rev)
224        return name in self.files
225
226    def mtnisdir(self, name, rev):
227        self.mtnloadmanifest(rev)
228        return name in self.dirs
229
230    def mtngetcerts(self, rev):
231        certs = {
232            b"author": b"<missing>",
233            b"date": b"<missing>",
234            b"changelog": b"<missing>",
235            b"branch": b"<missing>",
236        }
237        certlist = self.mtnrun(b"certs", rev)
238        # mtn < 0.45:
239        #   key "test@selenic.com"
240        # mtn >= 0.45:
241        #   key [ff58a7ffb771907c4ff68995eada1c4da068d328]
242        certlist = re.split(br'\n\n {6}key ["\[]', certlist)
243        for e in certlist:
244            m = self.cert_re.match(e)
245            if m:
246                name, value = m.groups()
247                value = value.replace(br'\"', b'"')
248                value = value.replace(br'\\', b'\\')
249                certs[name] = value
250        # Monotone may have subsecond dates: 2005-02-05T09:39:12.364306
251        # and all times are stored in UTC
252        certs[b"date"] = certs[b"date"].split(b'.')[0] + b" UTC"
253        return certs
254
255    # implement the converter_source interface:
256
257    def getheads(self):
258        if not self.revs:
259            return self.mtnrun(b"leaves").splitlines()
260        else:
261            return self.revs
262
263    def getchanges(self, rev, full):
264        if full:
265            raise error.Abort(
266                _(b"convert from monotone does not support --full")
267            )
268        revision = self.mtnrun(b"get_revision", rev).split(b"\n\n")
269        files = {}
270        ignoremove = {}
271        renameddirs = []
272        copies = {}
273        for e in revision:
274            m = self.add_file_re.match(e)
275            if m:
276                files[m.group(1)] = rev
277                ignoremove[m.group(1)] = rev
278            m = self.patch_re.match(e)
279            if m:
280                files[m.group(1)] = rev
281            # Delete/rename is handled later when the convert engine
282            # discovers an IOError exception from getfile,
283            # but only if we add the "from" file to the list of changes.
284            m = self.delete_re.match(e)
285            if m:
286                files[m.group(1)] = rev
287            m = self.rename_re.match(e)
288            if m:
289                toname = m.group(2)
290                fromname = m.group(1)
291                if self.mtnisfile(toname, rev):
292                    ignoremove[toname] = 1
293                    copies[toname] = fromname
294                    files[toname] = rev
295                    files[fromname] = rev
296                elif self.mtnisdir(toname, rev):
297                    renameddirs.append((fromname, toname))
298
299        # Directory renames can be handled only once we have recorded
300        # all new files
301        for fromdir, todir in renameddirs:
302            renamed = {}
303            for tofile in self.files:
304                if tofile in ignoremove:
305                    continue
306                if tofile.startswith(todir + b'/'):
307                    renamed[tofile] = fromdir + tofile[len(todir) :]
308                    # Avoid chained moves like:
309                    # d1(/a) => d3/d1(/a)
310                    # d2 => d3
311                    ignoremove[tofile] = 1
312            for tofile, fromfile in renamed.items():
313                self.ui.debug(
314                    b"copying file in renamed directory from '%s' to '%s'"
315                    % (fromfile, tofile),
316                    b'\n',
317                )
318                files[tofile] = rev
319                copies[tofile] = fromfile
320            for fromfile in renamed.values():
321                files[fromfile] = rev
322
323        return (files.items(), copies, set())
324
325    def getfile(self, name, rev):
326        if not self.mtnisfile(name, rev):
327            return None, None
328        try:
329            data = self.mtnrun(b"get_file_of", name, r=rev)
330        except Exception:
331            return None, None
332        self.mtnloadmanifest(rev)
333        node, attr = self.files.get(name, (None, b""))
334        return data, attr
335
336    def getcommit(self, rev):
337        extra = {}
338        certs = self.mtngetcerts(rev)
339        if certs.get(b'suspend') == certs[b"branch"]:
340            extra[b'close'] = b'1'
341        dateformat = b"%Y-%m-%dT%H:%M:%S"
342        return common.commit(
343            author=certs[b"author"],
344            date=dateutil.datestr(dateutil.strdate(certs[b"date"], dateformat)),
345            desc=certs[b"changelog"],
346            rev=rev,
347            parents=self.mtnrun(b"parents", rev).splitlines(),
348            branch=certs[b"branch"],
349            extra=extra,
350        )
351
352    def gettags(self):
353        tags = {}
354        for e in self.mtnrun(b"tags").split(b"\n\n"):
355            m = self.tag_re.match(e)
356            if m:
357                tags[m.group(1)] = m.group(2)
358        return tags
359
360    def getchangedfiles(self, rev, i):
361        # This function is only needed to support --filemap
362        # ... and we don't support that
363        raise NotImplementedError
364
365    def before(self):
366        # Check if we have a new enough version to use automate stdio
367        try:
368            versionstr = self.mtnrunsingle(b"interface_version")
369            version = float(versionstr)
370        except Exception:
371            raise error.Abort(
372                _(b"unable to determine mtn automate interface version")
373            )
374
375        if version >= 12.0:
376            self.automatestdio = True
377            self.ui.debug(
378                b"mtn automate version %f - using automate stdio\n" % version
379            )
380
381            # launch the long-running automate stdio process
382            self.mtnwritefp, self.mtnreadfp = self._run2(
383                b'automate', b'stdio', b'-d', self.path
384            )
385            # read the headers
386            read = self.mtnreadfp.readline()
387            if read != b'format-version: 2\n':
388                raise error.Abort(
389                    _(b'mtn automate stdio header unexpected: %s') % read
390                )
391            while read != b'\n':
392                read = self.mtnreadfp.readline()
393                if not read:
394                    raise error.Abort(
395                        _(
396                            b"failed to reach end of mtn automate "
397                            b"stdio headers"
398                        )
399                    )
400        else:
401            self.ui.debug(
402                b"mtn automate version %s - not using automate stdio "
403                b"(automate >= 12.0 - mtn >= 0.46 is needed)\n" % version
404            )
405
406    def after(self):
407        if self.automatestdio:
408            self.mtnwritefp.close()
409            self.mtnwritefp = None
410            self.mtnreadfp.close()
411            self.mtnreadfp = None
412