diff --git a/reviewboard/diffviewer/admin.py b/reviewboard/diffviewer/admin.py
index 46cc26d9917bb50599dc8d9cee9e6b54a5c14452..e5f1bf491a32a0f52264a5668edb8975cd1a4ca8 100644
--- a/reviewboard/diffviewer/admin.py
+++ b/reviewboard/diffviewer/admin.py
@@ -6,7 +6,8 @@ from pygments import highlight
 from pygments.formatters import HtmlFormatter
 from pygments.lexers import DiffLexer
 
-from reviewboard.diffviewer.models import FileDiff, DiffSet, DiffSetHistory
+from reviewboard.diffviewer.models import (DiffCommit, DiffSet, DiffSetHistory,
+                                           FileDiff)
 
 
 class FileDiffAdmin(admin.ModelAdmin):
@@ -54,10 +55,21 @@ class FileDiffInline(admin.StackedInline):
                      'legacy_parent_diff_hash')
 
 
+class DiffCommitAdmin(admin.ModelAdmin):
+    list_display = ('__str__',)
+    inlines = (FileDiffInline,)
+
+
+class DiffCommitInline(admin.StackedInline):
+    model = DiffCommit
+    extra = 0
+    inlines = (FileDiffInline,)
+
+
 class DiffSetAdmin(admin.ModelAdmin):
     list_display = ('__str__', 'revision', 'timestamp')
     raw_id_fields = ('history',)
-    inlines = (FileDiffInline,)
+    inlines = (DiffCommitInline, FileDiffInline)
     ordering = ('-timestamp',)
 
 
@@ -72,6 +84,7 @@ class DiffSetHistoryAdmin(admin.ModelAdmin):
     ordering = ('-timestamp',)
 
 
-admin.site.register(FileDiff, FileDiffAdmin)
+admin.site.register(DiffCommit, DiffCommitAdmin)
 admin.site.register(DiffSet, DiffSetAdmin)
 admin.site.register(DiffSetHistory, DiffSetHistoryAdmin)
+admin.site.register(FileDiff, FileDiffAdmin)
diff --git a/reviewboard/diffviewer/evolutions/__init__.py b/reviewboard/diffviewer/evolutions/__init__.py
index 05096bf59aed9f21cd16ac9198a5e2a45a0004a6..780b9d6a13b07acc61dc8084b47d13781bd24603 100644
--- a/reviewboard/diffviewer/evolutions/__init__.py
+++ b/reviewboard/diffviewer/evolutions/__init__.py
@@ -1,3 +1,4 @@
+
 from __future__ import unicode_literals
 
 
@@ -13,4 +14,5 @@ SEQUENCE = [
     'filediffdata_extra_data',
     'all_extra_data',
     'raw_diff_file_data',
+    'diffcommit_relations',
 ]
diff --git a/reviewboard/diffviewer/evolutions/diffcommit_relations.py b/reviewboard/diffviewer/evolutions/diffcommit_relations.py
new file mode 100644
index 0000000000000000000000000000000000000000..1f84ac5856b06073277fa98a0e88ae082b3f17fc
--- /dev/null
+++ b/reviewboard/diffviewer/evolutions/diffcommit_relations.py
@@ -0,0 +1,14 @@
+from __future__ import unicode_literals
+
+from django_evolution.mutations import AddField
+from django.db import models
+from djblets.db.fields import RelationCounterField
+
+
+MUTATIONS = [
+    AddField('DiffSet', 'commit_count', RelationCounterField, null=True),
+    AddField('DiffSet', 'file_count', RelationCounterField, null=True),
+    AddField('DiffCommit', 'file_count', RelationCounterField, null=True),
+    AddField('FileDiff', 'commit', models.ForeignKey, null=True,
+             related_model='diffviewer.DiffCommit'),
+]
diff --git a/reviewboard/diffviewer/models/__init__.py b/reviewboard/diffviewer/models/__init__.py
index 93d4f272b2c1f5b53f0dc0a6cc3f1660274b202a..b01b076e1675d77facaea292583caddc2d935c12 100644
--- a/reviewboard/diffviewer/models/__init__.py
+++ b/reviewboard/diffviewer/models/__init__.py
@@ -2,6 +2,7 @@
 
 from __future__ import unicode_literals
 
+from reviewboard.diffviewer.models.diffcommit import DiffCommit
 from reviewboard.diffviewer.models.diffset import DiffSet
 from reviewboard.diffviewer.models.diffset_history import DiffSetHistory
 from reviewboard.diffviewer.models.filediff import FileDiff
@@ -11,6 +12,7 @@ from reviewboard.diffviewer.models.raw_file_diff_data import RawFileDiffData
 
 
 __all__ = [
+    'DiffCommit',
     'DiffSet',
     'DiffSetHistory',
     'FileDiff',
diff --git a/reviewboard/diffviewer/models/diffcommit.py b/reviewboard/diffviewer/models/diffcommit.py
new file mode 100644
index 0000000000000000000000000000000000000000..8e2d9587bc8b5e8e44edaf253b9e560018ce9fc2
--- /dev/null
+++ b/reviewboard/diffviewer/models/diffcommit.py
@@ -0,0 +1,230 @@
+"""DiffCommit model definition."""
+
+from __future__ import unicode_literals
+
+from dateutil.tz import tzoffset
+from django.db import models
+from django.utils import timezone
+from django.utils.encoding import python_2_unicode_compatible
+from django.utils.functional import cached_property
+from django.utils.translation import ugettext_lazy as _
+from djblets.db.fields import JSONField
+
+from reviewboard.diffviewer.models.diffset import DiffSet
+from reviewboard.diffviewer.models.mixins import FileDiffCollectionMixin
+from reviewboard.diffviewer.validators import (COMMIT_ID_LENGTH,
+                                               validate_commit_id)
+
+
+@python_2_unicode_compatible
+class DiffCommit(FileDiffCollectionMixin, models.Model):
+    """A representation of a commit from a version control system.
+
+    A DiffSet on a Review Request that represents a commit history will have
+    one or more DiffCommits. Each DiffCommit will have one or more associated
+    FileDiffs (which also belong to the parent DiffSet).
+
+    The information stored herein is intended to fully represent the state of
+    a single commit in that history. The series of DiffCommits can be used to
+    re-create the original series of commits posted for review.
+    """
+
+    #: The maximum length of the author_name and committer_name fields.
+    NAME_MAX_LENGTH = 256
+
+    #: The maximum length of the author_email and committer_email fields.
+    EMAIL_MAX_LENGTH = 256
+
+    #: The date format that this model uses.
+    ISO_DATE_FORMAT = '%Y-%m-%d %H:%M:%S%z'
+
+    filename = models.CharField(
+        _('File Name'),
+        max_length=256,
+        help_text=_('The original file name of the diff.'))
+
+    diffset = models.ForeignKey(DiffSet, related_name='commits')
+
+    author_name = models.CharField(
+        _('Author Name'),
+        max_length=NAME_MAX_LENGTH,
+        help_text=_('The name of the commit author.'))
+    author_email = models.CharField(
+        _('Author Email'),
+        max_length=EMAIL_MAX_LENGTH,
+        help_text=_('The e-mail address of the commit author.'))
+    author_date_utc = models.DateTimeField(
+        _('Author Date'),
+        help_text=_('The date the commit was authored in UTC.'))
+    author_date_offset = models.IntegerField(
+        _('Author Date UTC Offset'),
+        help_text=_("The author's UTC offset."))
+
+    committer_name = models.CharField(
+        _('Committer Name'),
+        max_length=NAME_MAX_LENGTH,
+        help_text=_('The name of the committer (if applicable).'),
+        null=True,
+        blank=True)
+    committer_email = models.CharField(
+        _('Committer Email'),
+        max_length=EMAIL_MAX_LENGTH,
+        help_text=_('The e-mail address of the committer (if applicable).'),
+        null=True,
+        blank=True)
+    committer_date_utc = models.DateTimeField(
+        _('Committer Date'),
+        help_text=_('The date the commit was committed in UTC '
+                    '(if applicable).'),
+        null=True,
+        blank=True)
+    committer_date_offset = models.IntegerField(
+        _('Committer Date UTC Offset'),
+        help_text=_("The committer's UTC offset (if applicable)."),
+        null=True,
+        blank=True)
+
+    commit_message = models.TextField(
+        _('Description'),
+        help_text=_('The commit message.'))
+
+    commit_id = models.CharField(
+        _('Commit ID'),
+        max_length=COMMIT_ID_LENGTH,
+        validators=[validate_commit_id],
+        help_text=_('The unique identifier of the commit.'))
+
+    parent_id = models.CharField(
+        _('Parent ID'),
+        max_length=COMMIT_ID_LENGTH,
+        validators=[validate_commit_id],
+        help_text=_('The unique identifier of the parent commit.'))
+
+    #: A timestamp used for generating HTTP caching headers.
+    last_modified = models.DateTimeField(
+        _('Last Modified'),
+        default=timezone.now)
+
+    extra_data = JSONField(null=True)
+
+    @property
+    def author(self):
+        """The author's name and e-mail address.
+
+        This is formatted as :samp:`{author_name} <{author_email}>`.
+        """
+        return self._format_user(self.author_name, self.author_email)
+
+    @property
+    def author_date(self):
+        """The author date in its original timezone."""
+        tz = tzoffset(None, self.author_date_offset)
+        return self.author_date_utc.astimezone(tz)
+
+    @author_date.setter
+    def author_date(self, value):
+        """Set the author date.
+
+        Args:
+            value (datetime.datetime):
+                The date to set.
+        """
+        self.author_date_utc = value
+        self.author_date_offset = value.utcoffset().total_seconds()
+
+    @property
+    def committer(self):
+        """The committer's name and e-mail address (if applicable).
+
+        This will be formatted as :samp:`{committer_name} <{committer_email}>`
+        if both :py:attr:`committer_name` and :py:attr:`committer_email` are
+        set. Otherwise, it be whichever is defined. If neither are defined,
+        this will be ``None``.
+        """
+        return self._format_user(self.committer_name, self.committer_email)
+
+    @property
+    def committer_date(self):
+        """The committer date in its original timezone.
+
+        If the commit has no committer, this will be ``None``.
+        """
+        if self.committer_date_offset is None:
+            return None
+
+        tz = tzoffset(None, self.committer_date_offset)
+        return self.committer_date_utc.astimezone(tz)
+
+    @committer_date.setter
+    def committer_date(self, value):
+        """Set the committer date.
+
+        Args:
+            value (datetime.datetime):
+                The date to set.
+        """
+        self.committer_date_utc = value
+        self.committer_date_offset = value.utcoffset().total_seconds()
+
+    @cached_property
+    def summary(self):
+        """The first line of the commit message."""
+        summary = self.description
+
+        if summary:
+            summary = summary.split('\n', 1)[0].strip()
+
+        return summary
+
+    @cached_property
+    def summary_truncated(self):
+        """The first line of the commit message, truncated to 80 characters."""
+        summary = self.summary
+
+        if len(summary) > 80:
+            summary = summary[:77] + '...'
+
+        return summary
+
+    def __str__(self):
+        """Return a human-readable representation of the commit.
+
+        Returns:
+            unicode:
+            The commit ID and its summary (if available).
+        """
+        if self.summary:
+            return '%s: %s' % (self.commit_id, self.summary)
+
+        return self.commit_id
+
+    def _format_user(self, name, email):
+        """Format a name and e-mail address.
+
+        Args:
+            name (unicode):
+                The user's name.
+
+            email (unicode):
+                The user's e-mail address.
+
+        Returns:
+            unicode:
+            A pretty representation of the user and e-mail, or ``None`` if
+            neither are defined.
+        """
+        if name and email:
+            return '%s <%s>' % (name, email)
+        elif name:
+            return name
+        elif email:
+            return email
+
+        return None
+
+    class Meta:
+        app_label = 'diffviewer'
+        db_table = 'diffviewer_diffcommit'
+        verbose_name = _('Diff Commit')
+        verbose_name_plural = _('Diff Commits')
+        unique_together = ('diffset', 'commit_id')
diff --git a/reviewboard/diffviewer/models/diffset.py b/reviewboard/diffviewer/models/diffset.py
index c7cdf11e91221d9dceaac395ac6dc27a7bfe8e82..0dfe65d94d44654efb9a20e3182b88712160f93f 100644
--- a/reviewboard/diffviewer/models/diffset.py
+++ b/reviewboard/diffviewer/models/diffset.py
@@ -3,17 +3,18 @@
 from __future__ import unicode_literals
 
 from django.db import models
-from django.utils import six, timezone
+from django.utils import timezone
 from django.utils.encoding import python_2_unicode_compatible
 from django.utils.translation import ugettext_lazy as _
-from djblets.db.fields import JSONField
+from djblets.db.fields import JSONField, RelationCounterField
 
 from reviewboard.diffviewer.managers import DiffSetManager
+from reviewboard.diffviewer.models.mixins import FileDiffCollectionMixin
 from reviewboard.scmtools.models import Repository
 
 
 @python_2_unicode_compatible
-class DiffSet(models.Model):
+class DiffSet(FileDiffCollectionMixin, models.Model):
     """A revisioned collection of FileDiffs."""
 
     name = models.CharField(_('name'), max_length=256)
@@ -36,36 +37,12 @@ class DiffSet(models.Model):
         _('commit ID'), max_length=64, blank=True, null=True, db_index=True,
         help_text=_('The ID/revision this change is built upon.'))
 
+    commit_count = RelationCounterField('commits')
+
     extra_data = JSONField(null=True)
 
     objects = DiffSetManager()
 
-    def get_total_line_counts(self):
-        """Return the total line counts from all files in this diffset.
-
-        Returns:
-            dict:
-            A dictionary with the following keys:
-
-            * ``raw_insert_count``
-            * ``raw_delete_count``
-            * ``insert_count``
-            * ``delete_count``
-            * ``replace_count``
-            * ``equal_count``
-            * ``total_line_count``
-        """
-        counts = {}
-
-        for filediff in self.files.all():
-            for key, value in six.iteritems(filediff.get_line_counts()):
-                if counts.get(key) is None:
-                    counts[key] = value
-                elif value is not None:
-                    counts[key] += value
-
-        return counts
-
     def update_revision_from_history(self, diffset_history):
         """Update the revision of this diffset based on a diffset history.
 
diff --git a/reviewboard/diffviewer/models/filediff.py b/reviewboard/diffviewer/models/filediff.py
index 3507e57ae689225fd41f2a66b8d1a2d50455e746..3d8ad52a2454414a511063b4892ec770fc71472c 100644
--- a/reviewboard/diffviewer/models/filediff.py
+++ b/reviewboard/diffviewer/models/filediff.py
@@ -11,6 +11,7 @@ from django.utils.translation import ugettext_lazy as _
 from djblets.db.fields import Base64Field, JSONField
 
 from reviewboard.diffviewer.managers import FileDiffManager
+from reviewboard.diffviewer.models.diffcommit import DiffCommit
 from reviewboard.diffviewer.models.legacy_file_diff_data import \
     LegacyFileDiffData
 from reviewboard.diffviewer.models.raw_file_diff_data import RawFileDiffData
@@ -41,6 +42,11 @@ class FileDiff(models.Model):
                                 related_name='files',
                                 verbose_name=_('diff set'))
 
+    commit = models.ForeignKey(DiffCommit,
+                               related_name='files',
+                               verbose_name=_('diff commit'),
+                               null=True)
+
     source_file = models.CharField(_('source file'), max_length=1024)
     dest_file = models.CharField(_('destination file'), max_length=1024)
     source_revision = models.CharField(_('source file revision'),
diff --git a/reviewboard/diffviewer/models/mixins.py b/reviewboard/diffviewer/models/mixins.py
new file mode 100644
index 0000000000000000000000000000000000000000..f52b88e851917f08e51c19be67df6c8a54478b39
--- /dev/null
+++ b/reviewboard/diffviewer/models/mixins.py
@@ -0,0 +1,45 @@
+"""Diff viewer model mixins."""
+
+from __future__ import unicode_literals
+
+import collections
+
+from django.db import models
+from django.utils import six
+from djblets.db.fields import RelationCounterField
+
+
+class FileDiffCollectionMixin(models.Model):
+    """A mixin for models that consist of a colleciton of FileDiffs."""
+
+    file_count = RelationCounterField('files')
+
+    def get_total_line_counts(self):
+        """Return the total line counts of all child FileDiffs.
+
+        Returns:
+            dict:
+            A dictionary with the following keys:
+
+            * ``raw_insert_count``
+            * ``raw_delete_count``
+            * ``insert_count``
+            * ``delete_count``
+            * ``replace_count``
+            * ``equal_count``
+            * ``total_line_count``
+
+            Each entry maps to the sum of that line count type for all child
+            :py:class:`FileDiffs
+            <reviewboard.diffviewer.models.filediff.FileDiff>`.
+        """
+        counts = collections.defaultdict(int)
+
+        for filediff in self.files.all():
+            for key, value in six.iteritems(filediff.get_line_counts()):
+                counts[key] += value
+
+        return dict(counts)
+
+    class Meta:
+        abstract = True
diff --git a/reviewboard/diffviewer/validators.py b/reviewboard/diffviewer/validators.py
new file mode 100644
index 0000000000000000000000000000000000000000..96337559e10ee2fbca68aa2d425d9fc338b8e1cf
--- /dev/null
+++ b/reviewboard/diffviewer/validators.py
@@ -0,0 +1,19 @@
+"""Validators for diffviewer components."""
+
+from __future__ import unicode_literals
+
+import re
+
+from django.core.validators import RegexValidator
+from django.utils.translation import ugettext_lazy as _
+
+
+#: The maximum length of a commit ID.
+COMMIT_ID_LENGTH = 64
+
+#: A regular expression for matching commit IDs.
+COMMIT_ID_RE = re.compile(r'[A-Za-z0-9]{1,%s}' % COMMIT_ID_LENGTH)
+
+#: A validator for commit IDs.
+validate_commit_id = RegexValidator(COMMIT_ID_RE,
+                                    _('Commits must be alphanumeric.'))
diff --git a/reviewboard/reviews/models/review_request.py b/reviewboard/reviews/models/review_request.py
index 63995cd06cdc6919a89419b7e7ddff6cdff25064..4f7afa495e2b3bbee4c35003a7b8d8d1f72bcfe6 100644
--- a/reviewboard/reviews/models/review_request.py
+++ b/reviewboard/reviews/models/review_request.py
@@ -803,7 +803,6 @@ class ReviewRequest(BaseReviewRequestDetails):
             self._diffsets = list(
                 DiffSet.objects
                 .filter(history__pk=self.diffset_history_id)
-                .annotate(file_count=Count('files'))
                 .prefetch_related('files'))
 
         return self._diffsets
