1 # -*- coding: utf-8 -*-
3 # This file is part of MIL/PEER, licensed under GNU Affero GPLv3 or later.
4 # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
6 from __future__ import unicode_literals, print_function
8 from datetime import datetime
11 from subprocess import PIPE, Popen
12 from tempfile import NamedTemporaryFile
14 from django.conf import settings
15 from django.core.files.base import ContentFile
16 from django.db import models
17 from django.utils.encoding import python_2_unicode_compatible
18 from django.utils.translation import ugettext_lazy as _
20 from dvcs.signals import post_commit, post_merge
21 from dvcs.storage import GzipFileSystemStorage
23 # default repository path; make a setting for it
24 REPO_PATH = os.path.join(settings.MEDIA_ROOT, 'dvcs')
25 repo = GzipFileSystemStorage(location=REPO_PATH)
28 @python_2_unicode_compatible
29 class Revision(models.Model):
31 A document revision. The "parent"
32 argument points to the version against which this change has been
33 recorded. Initial text will have a null parent.
35 Gzipped text of the document is stored in a file.
37 author = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, verbose_name=_('author'))
38 author_name = models.CharField(
39 _('author name'), max_length=128, null=True, blank=True, help_text=_("Used if author is not set."))
40 author_email = models.CharField(
41 _('author email'), max_length=128, null=True, blank=True, help_text=_("Used if author is not set."))
42 # Any other author data?
43 # How do we identify an author?
45 parent = models.ForeignKey(
46 'self', null=True, blank=True, default=None, verbose_name=_('parent'), related_name="children")
48 merge_parent = models.ForeignKey(
49 'self', null=True, blank=True, default=None, verbose_name=_('merge parent'), related_name="merge_children")
51 description = models.TextField(_('description'), blank=True, default='')
52 created_at = models.DateTimeField(editable=False, db_index=True, default=datetime.now)
55 ordering = ('created_at',)
56 verbose_name = _("revision")
57 verbose_name_plural = _("revisions")
60 return "Id: %r, Parent %r, Data: %s" % (self.id, self.parent_id, self.get_text_path())
62 def get_text_path(self):
64 return re.sub(r'([0-9a-f]{2})([^.])', r'\1/\2', '%x.gz' % self.pk)
68 def save_text(self, content):
69 return repo.save(self.get_text_path(), ContentFile(content.encode('utf-8')))
73 return "%s %s <%s>" % (
74 self.author.first_name,
75 self.author.last_name,
84 def create(cls, text, parent=None, merge_parent=None, author=None, author_name=None, author_email=None,
89 '<dc:></dc:>', '').replace(
90 '<div class="img">', '<div>')
92 revision = cls.objects.create(
94 merge_parent=merge_parent,
96 author_name=author_name,
97 author_email=author_email,
98 description=description
100 revision.save_text(text)
103 def materialize(self):
104 f = repo.open(self.get_text_path())
105 text = f.read().decode('utf-8')
109 '<dc:></dc:>', '').replace(
110 '<div class="img">', '<div>')
113 def is_descendant_of(self, other):
117 self.parent is not None and (
118 self.parent.pk == other.pk or
119 self.parent.is_descendant_of(other)
122 self.merge_parent is not None and (
123 self.merge_parent.pk == other.pk or
124 self.merge_parent.is_descendant_of(other)
129 def get_common_ancestor_with(self, other):
130 # VERY naive approach.
131 if self.pk == other.pk:
133 if self.is_descendant_of(other):
135 if other.is_descendant_of(self):
138 if self.parent is not None:
139 parent_ca = self.parent.get_common_ancestor_with(other)
143 if self.merge_parent is not None:
144 merge_parent_ca = self.merge_parent.get_common_ancestor_with(other)
148 if parent_ca is None or parent_ca.created_at < merge_parent_ca.created_at:
149 return merge_parent_ca
153 def get_ancestors(self):
155 if self.parent is not None:
156 revs.add(self.parent)
157 revs.update(self.parent.get_ancestors())
158 if self.merge_parent is not None:
159 revs.add(self.merge_parent)
160 revs.update(self.merge_parent.get_ancestors())
164 @python_2_unicode_compatible
165 class Ref(models.Model):
166 """A reference pointing to a specific revision."""
168 revision = models.ForeignKey(
169 Revision, null=True, blank=True, default=None, verbose_name=_('revision'),
170 help_text=_("The document's revision."), editable=False)
173 return "ref:{0}->rev:{1}".format(self.id, self.revision_id)
175 def merge_text(self, base, local, remote):
176 """Override in subclass to have different kinds of merges."""
178 for f in local, base, remote:
179 temp = NamedTemporaryFile(delete=False)
182 files.append(temp.name)
183 p = Popen(['/usr/bin/diff3', '-mE', '-L', 'old', '-L', '', '-L', 'new'] + files, stdout=PIPE)
184 result, errs = p.communicate()
188 return result.decode('utf-8')
190 def merge_with(self, revision, author=None, author_name=None, author_email=None, description="Automatic merge."):
191 """Merges a given revision into the ref."""
192 if self.revision is None:
194 self.revision = revision
195 elif self.revision.pk == revision.pk or self.revision.is_descendant_of(revision):
198 elif revision.is_descendant_of(self.revision):
201 self.revision = revision
203 # Need to create a merge revision.
205 base = self.revision.get_common_ancestor_with(revision)
207 local_text = self.materialize().encode('utf-8')
208 base_text = base.materialize().encode('utf-8')
209 other_text = revision.materialize().encode('utf-8')
211 merge_text = self.merge_text(base_text, local_text, other_text)
213 merge_revision = Revision.create(
215 parent=self.revision,
216 merge_parent=revision,
218 author_name=author_name,
219 author_email=author_email,
220 description=description
222 self.revision = merge_revision
224 post_merge.send(sender=type(self), instance=self, fast_forward=fast_forward)
226 def materialize(self):
227 return self.revision.materialize() if self.revision is not None else ''
229 def commit(self, text, parent=False, author=None, author_name=None, author_email=None, description=''):
230 """Creates a new revision and sets it as the ref.
232 This will automatically merge the commit into the main branch,
233 if parent is not document's head.
235 :param unicode text: new version of the document
236 :param User author: the commiter
237 :param unicode author_name: commiter name (if ``author`` not specified)
238 :param unicode author_email: commiter e-mail (if ``author`` not specified)
242 # If parent revision not set explicitly, use your head.
243 parent = self.revision
245 # Warning: this will silently leave revs unreferenced.
246 rev = Revision.create(
249 author_name=author_name,
250 author_email=author_email,
251 description=description,
254 self.merge_with(rev, author=author, author_name=author_name, author_email=author_email)
256 post_commit.send(sender=type(self), instance=self)
259 revs = self.revision.get_ancestors()
260 revs.add(self.revision)
261 return sorted(revs, key=lambda x: x.created_at)