source: tailor/vcpx/repository/hg.py @ 1319

Revision 1319, 15.9 KB checked in by John Goerzen <jgoerzen@…>, 6 years ago (diff)

Generalize directory filtering and add it to commit
Without this, tailor was crashing whenever it tried to commit a changeset
from darcs that added an empty directory

Line 
1# -*- mode: python; coding: utf-8 -*-
2# :Progetto: vcpx -- Mercurial native backend
3# :Creato:   dom 11 set 2005 22:58:38 CEST
4# :Autore:   Lele Gaifax <lele@nautilus.homeip.net>
5#            Brendan Cully <brendan@kublai.com>
6# :Licenza:  GNU General Public License
7#
8
9"""
10This module implements the backends for Mercurial, using its native API
11instead of thru the command line.
12"""
13
14__docformat__ = 'reStructuredText'
15
16from mercurial import ui, hg, commands
17
18from vcpx.repository import Repository
19from vcpx.source import UpdatableSourceWorkingDir
20from vcpx.target import SynchronizableTargetWorkingDir
21
22
23class HgRepository(Repository):
24    METADIR = '.hg'
25
26    def _load(self, project):
27        Repository._load(self, project)
28        ppath = project.config.get(self.name, 'python-path')
29        if ppath:
30            from sys import path
31
32            if ppath not in path:
33                path.insert(0, ppath)
34        self.EXTRA_METADIRS = ['.hgtags']
35
36    def _validateConfiguration(self):
37        """
38        Mercurial expects all data to be in utf-8, so we disallow other encodings
39        """
40        Repository._validateConfiguration(self)
41
42        if self.encoding.upper() != 'UTF-8':
43            self.log.warning("Forcing UTF-8 encoding instead of " + self.encoding)
44            self.encoding = 'UTF-8'
45
46
47class HgWorkingDir(UpdatableSourceWorkingDir, SynchronizableTargetWorkingDir):
48    # UpdatableSourceWorkingDir
49    def _checkoutUpstreamRevision(self, revision):
50        """
51        Initial checkout (hg clone)
52        """
53
54        from os import mkdir, rename, rmdir
55        from os.path import exists, join
56
57        self._getUI()
58        # We have to clone the entire repository to be able to pull from it
59        # later. So a partial checkout is a full clone followed by an update
60        # directly to the desired revision.
61
62        # If the basedir does not exist, create it
63        if not exists(self.repository.basedir):
64            mkdir(self.repository.basedir)
65
66        # clone it only if .hg does not exist
67        if not exists(join(self.repository.basedir, ".hg")):
68            # Hg won't check out into an existing directory
69            checkoutdir = join(self.repository.basedir,".hgtmp")
70            opts = self._defaultOpts('clone')
71            opts['noupdate'] = True
72            commands.clone(self._ui, self.repository.repository, checkoutdir,
73                           **opts)
74            rename(join(checkoutdir, ".hg"), join(self.repository.basedir,".hg"))
75            rmdir(checkoutdir)
76        else:
77            # Does hgrc exist? If not, we write one
78            hgrc = join(self.repository.basedir, ".hg", "hgrc")
79            if not exists(hgrc):
80                hgrc = file(hgrc, "w")
81                hgrc.write("[paths]\ndefault = %s\ndefault-push = %s\n" %
82                           (self.repository.repository,
83                            self.repository.repository))
84                hgrc.close()
85
86        repo = self._getRepo()
87        node = self._getNode(repo, revision)
88
89        self.log.info('Extracting revision %r from %r into %r',
90                      revision, self.repository.repository, self.repository.basedir)
91        repo.update(node)
92
93        return self._changesetForRevision(repo, revision)
94
95    def _getUpstreamChangesets(self, sincerev):
96        """Fetch new changesets from the source"""
97        repo = self._getRepo()
98
99        self._hgCommand('pull', 'default')
100
101        from mercurial.node import bin
102        for rev in xrange(repo.changelog.rev(bin(sincerev)) + 1,
103                          repo.changelog.count()):
104            yield self._changesetForRevision(repo, str(rev))
105
106    def _applyChangeset(self, changeset):
107        repo = self._getRepo()
108        node = self._getNode(repo, changeset.revision)
109
110        self.log.info('Updating to %r', changeset.revision)
111        res = repo.update(node)
112        if res:
113            # Files in to-be-merged changesets not on the trunk will
114            # cause a merge error on update. If no files are modified,
115            # added, removed, or deleted, do update -C
116            modified, added, removed, deleted = repo.changes()[0:4]
117            conflicting = modified + added + removed + deleted
118            if conflicting:
119                return conflicting
120            return repo.update(node, force=True)
121
122    def _changesetForRevision(self, repo, revision):
123        from datetime import datetime
124        from vcpx.changes import Changeset, ChangesetEntry
125        from vcpx.tzinfo import FixedOffset
126
127        entries = []
128        node = self._getNode(repo, revision)
129        parents = repo.changelog.parents(node)
130        (manifest, user, date, files, message) = repo.changelog.read(node)
131
132        dt, tz = date
133        date = datetime.fromtimestamp(dt, FixedOffset(-tz/60)) # note the minus sign!
134
135        manifest = repo.manifest.read(manifest)
136
137        # To find adds, we get the manifests of any parents. If a file doesn't
138        # occur there, it's new.
139        pms = {}
140        for parent in repo.changelog.parents(node):
141            pms.update(repo.manifest.read(repo.changelog.read(parent)[0]))
142
143        # if files contains only '.hgtags', this is probably a tag cset.
144        # Tailor appears to only support tagging the current version, so only
145        # pass on tags that are for the immediate parents of the current node
146        tags = None
147        if files == ['.hgtags']:
148            tags = [tag for (tag, tagnode) in repo.tags().iteritems()
149                    if tagnode in parents]
150
151        # Don't include the file itself in the changeset. It's only useful
152        # to mercurial, and if we do end up making a tailor round trip
153        # the nodes will be wrong anyway.
154        if '.hgtags' in files:
155            files.remove('.hgtags')
156        if pms.has_key('.hgtags'):
157            del pms['.hgtags']
158
159        for f in files:
160            e = ChangesetEntry(f)
161            # find renames
162            fl = repo.file(f)
163            oldname = f in manifest and fl.renamed(manifest[f])
164            if oldname:
165                e.action_kind = ChangesetEntry.RENAMED
166                e.old_name = oldname[0]
167                # hg copy can copy the same file to multiple destinations
168                # Currently this is handled as multiple renames. It would
169                # probably be better to have ChangesetEntry.COPIED.
170                if pms.has_key(oldname[0]):
171                    pms.pop(oldname[0])
172            else:
173                if pms.has_key(f):
174                    e.action_kind = ChangesetEntry.UPDATED
175                else:
176                    e.action_kind = ChangesetEntry.ADDED
177
178            entries.append(e)
179
180        for df in [file for file in pms.iterkeys()
181                   if not manifest.has_key(file)]:
182            e = ChangesetEntry(df)
183            e.action_kind = ChangesetEntry.DELETED
184            entries.append(e)
185
186        from mercurial.node import hex
187        revision = hex(node)
188        return Changeset(revision, date, user, message, entries, tags=tags)
189
190    def _getUI(self):
191        try:
192            return self._ui
193        except AttributeError:
194            project = self.repository.projectref()
195            self._ui = ui.ui(project.verbose,
196                             project.config.get(self.repository.name,
197                                                'debug', False),
198                             not project.verbose, False)
199            return self._ui
200
201    def _getRepo(self):
202        try:
203            return self._hg
204        except AttributeError:
205            # dirstate walker uses simple string comparison between
206            # repo root and os.getcwd, so root should be canonified.
207            from os.path import realpath
208
209            ui = self._getUI()
210            self._hg = hg.repository(ui=ui, path=realpath(self.repository.basedir),
211                                     create=False)
212            # Pick up repository-specific UI settings.
213            self._ui = self._hg.ui
214            return self._hg
215
216    def _getNode(self, repo, revision):
217        """Convert a tailor revision ID into an hg node"""
218        if revision == "HEAD":
219            node = repo.changelog.tip()
220        else:
221            if revision == "INITIAL":
222                rev = "0"
223            else:
224                rev = revision
225            node = repo.changelog.lookup(rev)
226
227        return node
228
229    def _normalizeEntryPaths(self, entry):
230        """
231        Normalize the name and old_name of an entry.
232
233        This implementation uses ``mercurial.util.normpath()``, since
234        at this level hg is expecting UNIX style pathnames, with
235        forward slash"/" as separator, also under insane operating systems.
236        """
237
238        from mercurial.util import normpath
239
240        entry.name = normpath(self.repository.encode(entry.name))
241        if entry.old_name:
242            entry.old_name = normpath(self.repository.encode(entry.old_name))
243
244    def _removeDirs(self, names):
245        from os.path import isdir, join, normpath
246        """Remove the names that reference a directory."""
247        return [n for n in names
248                if not isdir(join(self.repository.basedir, normpath(n)))]
249        return notdirs
250
251    def _addPathnames(self, names):
252        from os.path import join
253
254        notdirs = self._removeDirs(names)
255        if notdirs:
256            self.log.info('Adding %s...', ', '.join(notdirs))
257            self._hg.add(notdirs)
258
259    def _commit(self, date, author, patchname, changelog=None, names=[]):
260        from calendar import timegm  # like mktime(), but returns UTC timestamp
261
262        encode = self.repository.encode
263
264        logmessage = []
265        if patchname:
266            logmessage.append(patchname)
267        if changelog:
268            logmessage.append(changelog)
269        if logmessage:
270            self.log.info('Committing %r...', logmessage[0])
271            logmessage = encode('\n'.join(logmessage))
272        else:
273            self.log.info('Committing...')
274            logmessage = "Empty changelog"
275
276        timestamp = timegm(date.utctimetuple())
277        timezone  = date.utcoffset().seconds + date.utcoffset().days * 24 * 3600
278
279        opts = {}
280        opts['message'] = logmessage
281        opts['user'] = encode(author)
282        opts['date'] =  '%d %d' % (timestamp, -timezone) # note the minus sign!
283        notdirs = self._removeDirs(names)
284        self._hgCommand('commit', *[encode(n) for n in notdirs], **opts)
285
286    def _tag(self, tag):
287        """ Tag the tip with a given identifier """
288        # TODO: keep a handle on the changeset holding this tag? Then
289        # we can extract author, log, date from it.
290
291        # This seems gross. I don't get why I'm getting a unicode tag when
292        # it's just ascii underneath. Something weird is happening in CVS.
293        tag = self.repository.encode(tag)
294
295        # CVS can't tell when a tag was applied so it tends to pass around
296        # too many. We want to support retagging so we can't just ignore
297        # duplicates. But we can safely ignore a tag if it is contained
298        # in the commit history from tip back to the last non-tag commit.
299        repo = self._getRepo()
300        tagnodes = repo.tags().values()
301        try:
302            tagnode = repo.tags()[tag]
303            # tag commit can't be merge, right?
304            parent = repo.changelog.parents(repo.changelog.tip())[0]
305            while parent in tagnodes:
306                if tagnode == parent:
307                    return
308                parent = repo.changelog.parents(parent)[0]
309        except KeyError:
310            pass
311        self._hgCommand('tag', tag)
312
313    def _defaultOpts(self, cmd):
314        # Not sure this is public. commands.parse might be, but this
315        # is easier, and while dispatch is easiest, you lose ui.
316        if hasattr(commands, 'findcmd'):
317            if commands.findcmd.func_code.co_argcount == 1:
318                findcmd = commands.findcmd
319            else:
320                def findcmd(cmd):
321                    return commands.findcmd(self._getUI(), cmd)
322        else:
323            findcmd = commands.find
324        return dict([(f[1].replace('-', '_'), f[2]) for f in findcmd(cmd)[1][1]])
325
326    def _hgCommand(self, cmd, *args, **opts):
327        import os
328
329        allopts = self._defaultOpts(cmd)
330        allopts.update(opts)
331        cmd = getattr(commands, cmd)
332        cwd = os.getcwd()
333        os.chdir(self.repository.basedir)
334        try:
335            cmd(self._ui, self._hg, *args, **allopts)
336        finally:
337            os.chdir(cwd)
338
339    def _removePathnames(self, names):
340        """Remove a sequence of entries"""
341
342        from os.path import join
343
344        repo = self._getRepo()
345
346        self.log.info('Removing %s...', ', '.join(names))
347        for name in names:
348            files = self._walk(name)
349            # We can't use isdir because the source has already
350            # removed the entry, so we do a dirstate lookup.
351            if files:
352                for f in self._walk(name):
353                    repo.remove([join(name, f)])
354            else:
355                repo.remove([name])
356
357    def _renamePathname(self, oldname, newname):
358        """Rename an entry"""
359
360        from os.path import join, isdir, normpath
361
362        repo = self._getRepo()
363
364        self.log.info('Renaming %r to %r...', oldname, newname)
365        if isdir(join(self.repository.basedir, normpath(newname))):
366            # Given lack of support for directories in current HG,
367            # loop over all files under the old directory and
368            # do a copy on them.
369            for f in self._walk(oldname):
370                oldpath = join(oldname, f)
371                repo.copy(oldpath, join(newname, f))
372                repo.remove([oldpath])
373        else:
374            repo.copy(oldname, newname)
375            repo.remove([oldname])
376
377    def _prepareTargetRepository(self):
378        """
379        Create the base directory if it doesn't exist, and the
380        repository as well in the new working directory.
381        """
382
383        from os.path import join, exists, realpath
384
385        self._getUI()
386
387        if exists(join(self.repository.basedir, self.repository.METADIR)):
388            create = 0
389        else:
390            create = 1
391            self.log.info('Initializing new repository in %r...', self.repository.basedir)
392        self._hg = hg.repository(ui=self._ui, path=realpath(self.repository.basedir),
393                                 create=create)
394
395    def _prepareWorkingDirectory(self, source_repo):
396        """
397        Create the .hgignore.
398        """
399
400        from os.path import join
401        from re import escape
402        from vcpx.dualwd import IGNORED_METADIRS
403
404        # Create the .hgignore file, that contains a regexp per line
405        # with all known VCs metadirs to be skipped.
406        ignore = open(join(self.repository.basedir, '.hgignore'), 'w')
407        ignore.write('\n'.join(['(^|/)%s($|/)' % escape(md)
408                                for md in IGNORED_METADIRS]))
409        ignore.write('\n')
410        if self.logfile.startswith(self.repository.basedir):
411            ignore.write('^')
412            ignore.write(self.logfile[len(self.repository.basedir)+1:])
413            ignore.write('$\n')
414        if self.state_file.filename.startswith(self.repository.basedir):
415            sfrelname = self.state_file.filename[len(self.repository.basedir)+1:]
416            ignore.write('^')
417            ignore.write(sfrelname)
418            ignore.write('$\n')
419            ignore.write('^')
420            ignore.write(sfrelname+'.old')
421            ignore.write('$\n')
422            ignore.write('^')
423            ignore.write(sfrelname+'.journal')
424            ignore.write('$\n')
425        ignore.close()
426
427    def _initializeWorkingDir(self):
428        self._hgCommand('add')
429
430    def _walk(self, subdir):
431        """
432        Returns the files mercurial knows about under subdir, relative
433        to subdir.
434        """
435        from os.path import join, split
436
437        files = []
438        for src, path in self._getRepo().dirstate.walk([subdir]):
439            # If subdir is a plain file, just return
440            if path == subdir:
441                return None
442            (hd, tl) = split(path)
443            while hd != subdir and hd != '':
444                hd, nt = split(hd)
445                tl = join(nt, tl)
446            files.append(tl)
447        return files
Note: See TracBrowser for help on using the repository browser.