From: Ɓukasz Rekucki Date: Tue, 22 Jun 2010 00:08:18 +0000 (+0200) Subject: Added DVCS application - mercurial on database. X-Git-Url: https://git.mdrn.pl/redakcja.git/commitdiff_plain/e33227021472d98ab797912e73427a9a71c5a531 Added DVCS application - mercurial on database. --- diff --git a/apps/dvcs/__init__.py b/apps/dvcs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/dvcs/admin.py b/apps/dvcs/admin.py new file mode 100644 index 00000000..c81d3b7b --- /dev/null +++ b/apps/dvcs/admin.py @@ -0,0 +1,5 @@ +from django.contrib.admin import site +from dvcs.models import Document, Change + +site.register(Document) +site.register(Change) diff --git a/apps/dvcs/models.py b/apps/dvcs/models.py new file mode 100644 index 00000000..01ab0389 --- /dev/null +++ b/apps/dvcs/models.py @@ -0,0 +1,146 @@ +from django.db import models +from django.contrib.auth.models import User +from django.utils.translation import ugettext_lazy as _ +from mercurial import mdiff, simplemerge +import pickle + +class Change(models.Model): + """ + Single document change related to previous change. The "parent" + argument points to the version against which this change has been + recorded. Initial text will have a null parent. + + Data contains a reverse diff needed to reproduce the initial document. + """ + author = models.ForeignKey(User) + patch = models.TextField(blank=True) + tree = models.ForeignKey('Document') + + parent = models.ForeignKey('self', + null=True, blank=True, default=None, + related_name="children") + + merge_parent = models.ForeignKey('self', + null=True, blank=True, default=None, + related_name="merge_children") + + description = models.TextField(blank=True, default='') + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ('created_at',) + + def __unicode__(self): + return "Id: %r, Tree %r, Parent %r, Patch '''\n%s'''" % (self.id, self.tree_id, self.parent_id, self.patch) + + @staticmethod + def make_patch(src, dst): + return pickle.dumps(mdiff.textdiff(src, dst)) + + def materialize(self): + changes = Change.objects.exclude(parent=None).filter( + tree=self.tree, + created_at__lte=self.created_at).order_by('created_at') + text = '' + for change in changes: + text = change.apply_to(text) + return text + + def make_child(self, patch, description): + return self.children.create(patch=patch, + tree=self.tree, + description=description) + + def make_merge_child(self, patch, description): + return self.merge_children.create(patch=patch, + tree=self.tree, + description=description) + + def apply_to(self, text): + return mdiff.patch(text, pickle.loads(self.patch.encode('ascii'))) + + + def merge_with(self, other): + assert self.tree_id == other.tree_id # same tree + if other.parent_id == self.pk: + # immediate child + return other + + local = self.materialize() + base = other.merge_parent.materialize() + remote = other.apply_to(base) + + merge = simplemerge.Merge3Text(base, local, remote) + result = ''.join(merge.merge_lines()) + patch = self.make_patch(local, result) + return self.children.create( + patch=patch, + merge_parent=other, tree=self.tree, description=u"Automatic merge") + + +class Document(models.Model): + """ + File in repository. + + """ + creator = models.ForeignKey(User) + head = models.ForeignKey(Change, + null=True, blank=True, default=None, + help_text=_("This document's current head.")) + + # Some meta-data + name = models.CharField(max_length=200, + help_text=_("Name for this file to display.")) + + def __unicode__(self): + return "{0}, HEAD: {1}".format(self.name, self.head_id) + + def materialize(self, version=None): + if self.head is None: + return u'' + if version is None: + version = self.head + elif not isinstance(version, Change): + version = self.change_set.get(pk=version) + return version.materialize() + + def commit(self, **kwargs): + if 'parent' not in kwargs: + parent = self.head + else: + parent = kwargs['parent'] + if not isinstance(parent, Change): + parent = Change.objects.get(pk=kwargs['parent']) + + if 'patch' not in kwargs: + if 'text' not in kwargs: + raise ValueError("You must provide either patch or target document.") + patch = Change.make_patch(self.materialize(version=parent), kwargs['text']) + else: + if 'text' in kwargs: + raise ValueError("You can provide only text or patch - not both") + patch = kwargs['patch'] + + old_head = self.head + if parent != old_head: + change = parent.make_merge_child(patch, kwargs.get('description', '')) + # not Fast-Forward - perform a merge + self.head = old_head.merge_with(change) + else: + self.head = parent.make_child(patch, kwargs.get('description', '')) + self.save() + return self.head + + def history(self): + return self.changes.all() + + @staticmethod + def listener_initial_commit(sender, instance, created, **kwargs): + if created: + instance.head = Change.objects.create( + author=instance.creator, + patch=pickle.dumps(mdiff.textdiff('', '')), + tree=instance) + instance.save() + +models.signals.post_save.connect(Document.listener_initial_commit, sender=Document) diff --git a/apps/dvcs/tests.py b/apps/dvcs/tests.py new file mode 100644 index 00000000..af19d782 --- /dev/null +++ b/apps/dvcs/tests.py @@ -0,0 +1,120 @@ +from django.test import TestCase +from dvcs.models import Change, Document + +class DocumentModelTests(TestCase): + + def assertTextEqual(self, given, expected): + return self.assertEqual(given, expected, + "Expected '''%s'''\n differs from text: '''%s'''" % (expected, given) + ) + + def test_empty_file(self): + doc = Document.objects.create(name=u"Sample Document") + self.assert_(doc.head is not None) + self.assertEqual(doc.materialize(), u"") + + def test_single_commit(self): + doc = Document.objects.create(name=u"Sample Document") + doc.commit(text=u"Ala ma kota", description="Commit #1") + self.assert_(doc.head is not None) + self.assertEqual(doc.change_set.count(), 2) + self.assertEqual(doc.materialize(), u"Ala ma kota") + + def test_chained_commits(self): + doc = Document.objects.create(name=u"Sample Document") + c1 = doc.commit(description="Commit #1", text=u""" + Line #1 + Line #2 is cool + """) + c2 = doc.commit(description="Commit #2", text=u""" + Line #1 + Line #2 is hot + """) + c3 = doc.commit(description="Commit #3", text=u""" + Line #1 + ... is hot + Line #3 ate Line #2 + """) + self.assert_(doc.head is not None) + self.assertEqual(doc.change_set.count(), 4) + + self.assertEqual(doc.materialize(), u""" + Line #1 + ... is hot + Line #3 ate Line #2 + """) + self.assertEqual(doc.materialize(version=c3), u""" + Line #1 + ... is hot + Line #3 ate Line #2 + """) + self.assertEqual(doc.materialize(version=c2), u""" + Line #1 + Line #2 is hot + """) + self.assertEqual(doc.materialize(version=c1), """ + Line #1 + Line #2 is cool + """) + + + def test_parallel_commit_noconflict(self): + doc = Document.objects.create(name=u"Sample Document") + self.assert_(doc.head is not None) + base = doc.head + base = doc.commit(description="Commit #1", text=u""" + Line #1 + Line #2 +""") + + c1 = doc.commit(description="Commit #2", text=u""" + Line #1 is hot + Line #2 +""", parent=base) + self.assertTextEqual(c1.materialize(), u""" + Line #1 is hot + Line #2 +""") + c2 = doc.commit(description="Commit #3", text=u""" + Line #1 + Line #2 + Line #3 +""", parent=base) + self.assertEqual(doc.change_set.count(), 5) + self.assertTextEqual(doc.materialize(), u""" + Line #1 is hot + Line #2 + Line #3 +""") + + def test_parallel_commit_conflict(self): + doc = Document.objects.create(name=u"Sample Document") + self.assert_(doc.head is not None) + base = doc.head + base = doc.commit(description="Commit #1", text=u""" +Line #1 +Line #2 +Line #3 +""") + + c1 = doc.commit(description="Commit #2", text=u""" +Line #1 +Line #2 is hot +Line #3 +""", parent=base) + c2 = doc.commit(description="Commit #3", text=u""" +Line #1 +Line #2 is cool +Line #3 +""", parent=base) + self.assertEqual(doc.change_set.count(), 5) + self.assertTextEqual(doc.materialize(), u""" +Line #1 +<<<<<<< +Line #2 is hot +======= +Line #2 is cool +>>>>>>> +Line #3 +""") + diff --git a/apps/dvcs/views.py b/apps/dvcs/views.py new file mode 100644 index 00000000..03b258a0 --- /dev/null +++ b/apps/dvcs/views.py @@ -0,0 +1,16 @@ +# Create your views here. +from django.views.generic.simple import direct_to_template +from dvcs.models import Document + +def document_list(request, template_name="dvcs/document_list.html"): + return direct_to_template(request, template_name, { + "documents": Document.objects.all(), + }) + +def document_history(request, docid, template_name="dvcs/document_history.html"): + document = Document.objects.get(pk=docid) + return direct_to_template(request, template_name, { + "document": document, + "changes": document.history(), + }) + diff --git a/apps/wiki/models.py b/apps/wiki/models.py index b7527f39..a8a94057 100644 --- a/apps/wiki/models.py +++ b/apps/wiki/models.py @@ -72,7 +72,7 @@ class DocumentStorage(object): return document def create_document(self, text, name): - title = u', '.join(p.title for p in split_name(name)) + title = u', '.join(p.title() for p in split_name(name)) if text is None: text = u'' diff --git a/apps/wiki/views.py b/apps/wiki/views.py index c41edd38..a1c3097c 100644 --- a/apps/wiki/views.py +++ b/apps/wiki/views.py @@ -12,6 +12,8 @@ from wiki.helpers import (JSONResponse, JSONFormInvalid, JSONServerError, ajax_require_permission, recursive_groupby) from django import http +from django.contrib.auth.decorators import login_required + from wiki.models import getstorage, DocumentNotFound, normalize_name, split_name, join_name from wiki.forms import DocumentTextSaveForm, DocumentTagForm, DocumentCreateForm from datetime import datetime @@ -124,11 +126,11 @@ def create_missing(request, name): form = DocumentCreateForm(request.POST, request.FILES) if form.is_valid(): doc = storage.create_document( - id=form.cleaned_data['id'], + name=form.cleaned_data['id'], text=form.cleaned_data['text'], ) - return http.HttpResponseRedirect(reverse("wiki_details", args=[doc.name])) + return http.HttpResponseRedirect(reverse("wiki_editor", args=[doc.name])) else: form = DocumentCreateForm(initial={ "id": name.replace(" ", "_"), @@ -302,3 +304,8 @@ def publish(request, name): return JSONResponse({"result": api.publish_book(document)}) except wlapi.APICallException, e: return JSONServerError({"message": str(e)}) + +@login_required +def status_report(request): + pass + diff --git a/lib/vstorage/__init__.py b/lib/vstorage/__init__.py index 8d22e10c..5674c1ba 100644 --- a/lib/vstorage/__init__.py +++ b/lib/vstorage/__init__.py @@ -20,6 +20,8 @@ os.environ['HGMERGE'] = "internal:merge" import mercurial.hg import mercurial.revlog import mercurial.util +from mercurial.match import exact as hg_exact_match +from mercurial.cmdutil import walkchangerevs from vstorage.hgui import SilentUI @@ -209,7 +211,7 @@ class VersionedStorage(object): try: filectx_tip = changectx[repo_file] - current_page_rev = filectx_tip.filerev() + current_page_rev = filectx_tip.rev() except mercurial.revlog.LookupError: self.repo.add([repo_file]) current_page_rev = -1 @@ -273,7 +275,7 @@ class VersionedStorage(object): if ctx is None: raise DocumentNotFound(title) - return ctx.data().decode(self.charset, 'replace'), ctx.filerev() + return ctx.data().decode(self.charset, 'replace'), ctx.rev() def page_text_by_tag(self, title, tag): """Read unicode text of a taged page.""" @@ -282,7 +284,7 @@ class VersionedStorage(object): try: ctx = self.repo[tag][fname] - return ctx.data().decode(self.charset, 'replace'), ctx.filerev() + return ctx.data().decode(self.charset, 'replace'), ctx.rev() except IndexError: raise DocumentNotFound(fname) @@ -305,7 +307,7 @@ class VersionedStorage(object): raise DocumentNotFound(title) return { - "revision": fctx.filerev(), + "revision": fctx.rev(), "date": datetime.datetime.fromtimestamp(fctx.date()[0]), "author": fctx.user().decode("utf-8", 'replace'), "comment": fctx.description().decode("utf-8", 'replace'), @@ -324,59 +326,49 @@ class VersionedStorage(object): """ return guess_mime(self._file_path(title)) - def _find_filectx(self, title, rev=None): + def _find_filectx(self, title, rev=None, oldest=0, newest= -1): """Find the last revision in which the file existed.""" - tip = self._changectx() # start with tip - - def tree_search(tip, repo_file): - logging.info("Searching for %r", repo_file) - current = tip - visited = set() - - stack = [current] - visited.add(current) - - while repo_file not in current: - if not stack: - raise LookupError - - current = stack.pop() - for parent in current.parents(): - if parent not in visited: - stack.append(parent) - visited.add(parent) - - fctx = current[repo_file] - if rev is not None: - fctx = fctx.filectx(rev) - fctx.filerev() - return fctx - - try: - return tree_search(tip, self._title_to_file(title)) - except (IndexError, LookupError) as e: - logging.info("XML file not found, trying plain") - try: - return tree_search(tip, self._title_to_file(title, type='')) - except (IndexError, LookupError) as e: - raise DocumentNotFound(title) - - def page_history(self, title): + if rev is not None: + oldest, newest = rev, -1 + opts = {"follow": True, "rev": ["%s:%s" % (newest, oldest)]} + def prepare(ctx, fns): + pass + xml_file = self._title_to_file(title) + matchfn = hg_exact_match(self.repo.root, self.repo.getcwd(), [xml_file]) + generator = walkchangerevs(self.repo, matchfn, opts, prepare) + + last = None + current_name = xml_file + for change in generator: + fctx = change[current_name] + renamed = fctx.renamed() + if renamed: + current_name = renamed[0] + last = change + + if last is not None: + return last[current_name] + + # not found + raise DocumentNotFound(title) + + def page_history(self, title, oldest=0, newest= -1): """Iterate over the page's history.""" + opts = {"follow": True, "rev": ["%s:%s" % (newest, oldest)]} + prepare = lambda * args: True + repo_file = self._title_to_file(title) + matchfn = hg_exact_match(self.repo.root, self.repo.getcwd(), [repo_file]) + generator = walkchangerevs(self.repo, matchfn, opts, prepare) - filectx_tip = self._find_filectx(title) - - maxrev = filectx_tip.filerev() - minrev = 0 - for rev in range(maxrev, minrev - 1, -1): - filectx = filectx_tip.filectx(rev) - date = datetime.datetime.fromtimestamp(filectx.date()[0]) - author = filectx.user().decode('utf-8', 'replace') - comment = filectx.description().decode("utf-8", 'replace') - tags = [t.rsplit('#', 1)[-1] for t in filectx.changectx().tags() if '#' in t] + for changeset in generator: + changeset + date = datetime.datetime.fromtimestamp(changeset.date()[0]) + author = changeset.user().decode('utf-8', 'replace') + comment = changeset.description().decode("utf-8", 'replace') + tags = [t.rsplit('#', 1)[-1] for t in changeset.tags() if '#' in t] yield { - "version": rev, + "version": changeset.rev(), "date": date, "author": author, "description": comment, @@ -398,25 +390,21 @@ class VersionedStorage(object): user=user, message=message, date=None, ) - def history(self): + def history(self, newest=None): """Iterate over the history of entire wiki.""" + opts = {"follow": False, "rev": newest} # follow doesn't make sense + prepare = lambda * args: True + repo_file = self._title_to_file(title) + matchfn = hg_exact_match(self.repo.root, self.repo.getcwd(), [repo_file]) + generator = walkchangerevs(self.repo, matchfn, opts, prepare) - changectx = self._changectx() - maxrev = changectx.rev() - minrev = 0 - for wiki_rev in range(maxrev, minrev - 1, -1): - change = self.repo.changectx(wiki_rev) + for change in generator: date = datetime.datetime.fromtimestamp(change.date()[0]) author = change.user().decode('utf-8', 'replace') comment = change.description().decode("utf-8", 'replace') for repo_file in change.files(): - if repo_file.startswith(self.repo_prefix): - title = self._file_to_title(repo_file) - try: - rev = change[repo_file].filerev() - except mercurial.revlog.LookupError: - rev = -1 - yield title, rev, date, author, comment + title = self._file_to_title(repo_file) + yield title, change.rev(), date, author, comment def all_pages(self, type=''): tip = self.repo['tip'] @@ -425,22 +413,6 @@ class VersionedStorage(object): if not filename.startswith('.') and filename.endswith(type) ] - def changed_since(self, rev): - """Return all pages that changed since specified repository revision.""" - - try: - last = self.repo.lookup(int(rev)) - except IndexError: - for page in self.all_pages(): - yield page - return - current = self.repo.lookup('tip') - status = self.repo.status(current, last) - modified, added, removed, deleted, unknown, ignored, clean = status - for filename in modified + added + removed + deleted: - if filename.startswith(self.repo_prefix): - yield self._file_to_title(filename) - def revert(self, pageid, rev, **commit_args): """ Make the given version of page the current version (reverting changes). """ diff --git a/redakcja/settings/common.py b/redakcja/settings/common.py index 7b32ba63..846289ae 100644 --- a/redakcja/settings/common.py +++ b/redakcja/settings/common.py @@ -116,6 +116,7 @@ INSTALLED_APPS = ( 'south', 'sorl.thumbnail', 'filebrowser', + 'dvcs', 'wiki', 'toolbar', @@ -144,3 +145,4 @@ try: from redakcja.settings.compress import * except ImportError: pass +