Add version control system functionality with branch management, history tracking, and merge operations

This commit is contained in:
pacnpal
2025-02-06 19:29:23 -05:00
parent 6fa807f4b6
commit f3d28817a5
26 changed files with 2935 additions and 508 deletions

View File

@@ -1,26 +1,9 @@
# history_tracking/apps.py
from django.apps import AppConfig
class HistoryTrackingConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "history_tracking"
default_auto_field = 'django.db.models.BigAutoField'
name = 'history_tracking'
def ready(self):
from django.apps import apps
from .mixins import HistoricalChangeMixin
# Get the Park model
try:
Park = apps.get_model('parks', 'Park')
ParkArea = apps.get_model('parks', 'ParkArea')
# Apply mixin to historical models
if HistoricalChangeMixin not in Park.history.model.__bases__:
Park.history.model.__bases__ = (HistoricalChangeMixin,) + Park.history.model.__bases__
if HistoricalChangeMixin not in ParkArea.history.model.__bases__:
ParkArea.history.model.__bases__ = (HistoricalChangeMixin,) + ParkArea.history.model.__bases__
except LookupError:
# Models might not be loaded yet
pass
"""Register signals when the app is ready"""
from . import signals # Import signals to register them

View File

@@ -0,0 +1,177 @@
from typing import Optional, List, Dict, Any, Tuple
from django.db import transaction
from django.core.exceptions import ValidationError
from django.utils import timezone
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from .models import VersionBranch, VersionTag, ChangeSet
User = get_user_model()
class BranchManager:
"""Manages version control branch operations"""
@transaction.atomic
def create_branch(self, name: str, parent: Optional[VersionBranch] = None,
user: Optional[User] = None) -> VersionBranch:
"""Create a new version branch"""
branch = VersionBranch.objects.create(
name=name,
parent=parent,
created_by=user,
metadata={
'created_from': parent.name if parent else 'root',
'created_at': timezone.now().isoformat()
}
)
branch.full_clean()
return branch
@transaction.atomic
def merge_branches(self, source: VersionBranch, target: VersionBranch,
user: Optional[User] = None) -> Tuple[bool, List[Dict[str, Any]]]:
"""
Merge source branch into target branch
Returns: (success, conflicts)
"""
if not source.is_active or not target.is_active:
raise ValidationError("Cannot merge inactive branches")
merger = MergeStrategy()
success, conflicts = merger.auto_merge(source, target)
if success:
# Record successful merge
ChangeSet.objects.create(
branch=target,
created_by=user,
description=f"Merged branch '{source.name}' into '{target.name}'",
metadata={
'merge_source': source.name,
'merge_target': target.name,
'merged_at': timezone.now().isoformat()
},
status='applied'
)
return success, conflicts
def list_branches(self, include_inactive: bool = False) -> List[VersionBranch]:
"""Get all branches with their relationships"""
queryset = VersionBranch.objects.select_related('parent')
if not include_inactive:
queryset = queryset.filter(is_active=True)
return list(queryset)
class ChangeTracker:
"""Tracks and manages changes across the system"""
@transaction.atomic
def record_change(self, instance: Any, change_type: str,
branch: VersionBranch, user: Optional[User] = None,
metadata: Optional[Dict] = None) -> ChangeSet:
"""Record a change in the system"""
if not hasattr(instance, 'history'):
raise ValueError("Instance must be a model with history tracking enabled")
# Create historical record by saving the instance
instance.save()
historical_record = instance.history.first()
if not historical_record:
raise ValueError("Failed to create historical record")
# Create changeset
content_type = ContentType.objects.get_for_model(historical_record)
changeset = ChangeSet.objects.create(
branch=branch,
created_by=user,
description=f"{change_type} operation on {instance._meta.model_name}",
metadata=metadata or {},
status='pending',
content_type=content_type,
object_id=historical_record.pk
)
return changeset
def get_changes(self, branch: VersionBranch) -> List[ChangeSet]:
"""Get all changes in a branch ordered by creation time"""
return list(ChangeSet.objects.filter(branch=branch).order_by('created_at'))
class MergeStrategy:
"""Handles merge operations and conflict resolution"""
def auto_merge(self, source: VersionBranch,
target: VersionBranch) -> Tuple[bool, List[Dict[str, Any]]]:
"""
Attempt automatic merge between branches
Returns: (success, conflicts)
"""
conflicts = []
# Get all changes since branch creation
source_changes = ChangeSet.objects.filter(
branch=source,
status='applied'
).order_by('created_at')
target_changes = ChangeSet.objects.filter(
branch=target,
status='applied'
).order_by('created_at')
# Detect conflicts
for source_change in source_changes:
for target_change in target_changes:
if self._detect_conflict(source_change, target_change):
conflicts.append({
'source_change': source_change.pk,
'target_change': target_change.pk,
'type': 'content_conflict',
'description': 'Conflicting changes detected'
})
if conflicts:
return False, conflicts
# No conflicts, apply source changes to target
for change in source_changes:
self._apply_change_to_branch(change, target)
return True, []
def _detect_conflict(self, change1: ChangeSet, change2: ChangeSet) -> bool:
"""Check if two changes conflict with each other"""
# Get historical instances
instance1 = change1.historical_instance
instance2 = change2.historical_instance
if not (instance1 and instance2):
return False
# Same model and instance ID indicates potential conflict
return (
instance1._meta.model == instance2._meta.model and
instance1.id == instance2.id
)
@transaction.atomic
def _apply_change_to_branch(self, change: ChangeSet,
target_branch: VersionBranch) -> None:
"""Apply a change from one branch to another"""
# Create new changeset in target branch
new_changeset = ChangeSet.objects.create(
branch=target_branch,
description=f"Applied change from '{change.branch.name}'",
metadata={
'source_change': change.pk,
'source_branch': change.branch.name
},
status='pending',
content_type=change.content_type,
object_id=change.object_id
)
new_changeset.status = 'applied'
new_changeset.save()

View File

@@ -0,0 +1,220 @@
# Generated by Django 5.1.6 on 2025-02-06 22:00
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("contenttypes", "0002_remove_content_type_name"),
("history_tracking", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="VersionBranch",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255, unique=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("metadata", models.JSONField(blank=True, default=dict)),
("is_active", models.BooleanField(default=True)),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
),
),
(
"parent",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="children",
to="history_tracking.versionbranch",
),
),
],
options={
"ordering": ["-created_at"],
},
),
migrations.CreateModel(
name="ChangeSet",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("description", models.TextField(blank=True)),
("metadata", models.JSONField(blank=True, default=dict)),
("dependencies", models.JSONField(blank=True, default=dict)),
(
"status",
models.CharField(
choices=[
("pending", "Pending"),
("applied", "Applied"),
("failed", "Failed"),
("reverted", "Reverted"),
],
default="pending",
max_length=20,
),
),
("object_id", models.PositiveIntegerField()),
(
"content_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.contenttype",
),
),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
),
),
(
"branch",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="changesets",
to="history_tracking.versionbranch",
),
),
],
options={
"ordering": ["-created_at"],
},
),
migrations.CreateModel(
name="VersionTag",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255, unique=True)),
("object_id", models.PositiveIntegerField()),
("created_at", models.DateTimeField(auto_now_add=True)),
("metadata", models.JSONField(blank=True, default=dict)),
(
"branch",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="tags",
to="history_tracking.versionbranch",
),
),
(
"content_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.contenttype",
),
),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["-created_at"],
},
),
migrations.AddIndex(
model_name="versionbranch",
index=models.Index(fields=["name"], name="history_tra_name_cf8692_idx"),
),
migrations.AddIndex(
model_name="versionbranch",
index=models.Index(
fields=["parent"], name="history_tra_parent__c645fa_idx"
),
),
migrations.AddIndex(
model_name="versionbranch",
index=models.Index(
fields=["created_at"], name="history_tra_created_6f9fc9_idx"
),
),
migrations.AddIndex(
model_name="changeset",
index=models.Index(
fields=["branch"], name="history_tra_branch__0c1728_idx"
),
),
migrations.AddIndex(
model_name="changeset",
index=models.Index(
fields=["created_at"], name="history_tra_created_c0fe58_idx"
),
),
migrations.AddIndex(
model_name="changeset",
index=models.Index(fields=["status"], name="history_tra_status_93e04d_idx"),
),
migrations.AddIndex(
model_name="changeset",
index=models.Index(
fields=["content_type", "object_id"],
name="history_tra_content_9f97ff_idx",
),
),
migrations.AddIndex(
model_name="versiontag",
index=models.Index(fields=["name"], name="history_tra_name_38da60_idx"),
),
migrations.AddIndex(
model_name="versiontag",
index=models.Index(
fields=["branch"], name="history_tra_branch__0a9a55_idx"
),
),
migrations.AddIndex(
model_name="versiontag",
index=models.Index(
fields=["created_at"], name="history_tra_created_7a1501_idx"
),
),
migrations.AddIndex(
model_name="versiontag",
index=models.Index(
fields=["content_type", "object_id"],
name="history_tra_content_0892f3_idx",
),
),
]

View File

@@ -1,14 +1,18 @@
# history_tracking/models.py
from django.db import models
from django.contrib.contenttypes.models import ContentType
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.auth import get_user_model
from simple_history.models import HistoricalRecords
from .mixins import HistoricalChangeMixin
from typing import Any, Type, TypeVar, cast
from typing import Any, Type, TypeVar, cast, Optional
from django.db.models import QuerySet
from django.core.exceptions import ValidationError
from django.utils import timezone
T = TypeVar('T', bound=models.Model)
User = get_user_model()
class HistoricalModel(models.Model):
"""Abstract base class for models with history tracking"""
id = models.BigAutoField(primary_key=True)
@@ -47,3 +51,124 @@ class HistoricalSlug(models.Model):
def __str__(self) -> str:
return f"{self.content_type} - {self.object_id} - {self.slug}"
class VersionBranch(models.Model):
"""Represents a version control branch for tracking parallel development"""
name = models.CharField(max_length=255, unique=True)
parent = models.ForeignKey('self', null=True, blank=True, on_delete=models.CASCADE, related_name='children')
created_at = models.DateTimeField(auto_now_add=True)
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
metadata = models.JSONField(default=dict, blank=True)
is_active = models.BooleanField(default=True)
class Meta:
ordering = ['-created_at']
indexes = [
models.Index(fields=['name']),
models.Index(fields=['parent']),
models.Index(fields=['created_at']),
]
def __str__(self) -> str:
return f"{self.name} ({'active' if self.is_active else 'inactive'})"
def clean(self) -> None:
# Prevent circular references
if self.parent and self.pk:
branch = self.parent
while branch:
if branch.pk == self.pk:
raise ValidationError("Circular branch reference detected")
branch = branch.parent
class VersionTag(models.Model):
"""Tags specific versions for reference (releases, milestones, etc)"""
name = models.CharField(max_length=255, unique=True)
branch = models.ForeignKey(VersionBranch, on_delete=models.CASCADE, related_name='tags')
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
historical_instance = GenericForeignKey('content_type', 'object_id')
created_at = models.DateTimeField(auto_now_add=True)
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
metadata = models.JSONField(default=dict, blank=True)
class Meta:
ordering = ['-created_at']
indexes = [
models.Index(fields=['name']),
models.Index(fields=['branch']),
models.Index(fields=['created_at']),
models.Index(fields=['content_type', 'object_id']),
]
def __str__(self) -> str:
return f"{self.name} ({self.branch.name})"
class ChangeSet(models.Model):
"""Groups related changes together for atomic version control operations"""
branch = models.ForeignKey(VersionBranch, on_delete=models.CASCADE, related_name='changesets')
created_at = models.DateTimeField(auto_now_add=True)
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
description = models.TextField(blank=True)
metadata = models.JSONField(default=dict, blank=True)
dependencies = models.JSONField(default=dict, blank=True)
status = models.CharField(
max_length=20,
choices=[
('pending', 'Pending'),
('applied', 'Applied'),
('failed', 'Failed'),
('reverted', 'Reverted')
],
default='pending'
)
# Instead of directly relating to HistoricalRecord, use GenericForeignKey
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
historical_instance = GenericForeignKey('content_type', 'object_id')
class Meta:
ordering = ['-created_at']
indexes = [
models.Index(fields=['branch']),
models.Index(fields=['created_at']),
models.Index(fields=['status']),
models.Index(fields=['content_type', 'object_id']),
]
def __str__(self) -> str:
return f"ChangeSet {self.pk} ({self.branch.name} - {self.status})"
def apply(self) -> None:
"""Apply the changeset to the target branch"""
if self.status != 'pending':
raise ValidationError(f"Cannot apply changeset with status: {self.status}")
try:
# Apply changes through the historical instance
if self.historical_instance:
instance = self.historical_instance.instance
if instance:
instance.save()
self.status = 'applied'
except Exception as e:
self.status = 'failed'
self.metadata['error'] = str(e)
self.save()
def revert(self) -> None:
"""Revert the changes in this changeset"""
if self.status != 'applied':
raise ValidationError(f"Cannot revert changeset with status: {self.status}")
try:
# Revert changes through the historical instance
if self.historical_instance:
instance = self.historical_instance.instance
if instance:
instance.save()
self.status = 'reverted'
except Exception as e:
self.metadata['revert_error'] = str(e)
self.save()

138
history_tracking/signals.py Normal file
View File

@@ -0,0 +1,138 @@
from django.db.models.signals import post_save
from django.dispatch import receiver
from simple_history.signals import post_create_historical_record
from django.contrib.auth import get_user_model
from django.db import transaction
from .models import VersionBranch, ChangeSet, HistoricalModel
from .managers import ChangeTracker
import threading
User = get_user_model()
# Thread-local storage for tracking active changesets
_changeset_context = threading.local()
def get_current_branch():
"""Get the currently active branch for the thread"""
return getattr(_changeset_context, 'current_branch', None)
def set_current_branch(branch):
"""Set the active branch for the current thread"""
_changeset_context.current_branch = branch
def clear_current_branch():
"""Clear the active branch for the current thread"""
if hasattr(_changeset_context, 'current_branch'):
del _changeset_context.current_branch
class ChangesetContextManager:
"""Context manager for tracking changes in a specific branch"""
def __init__(self, branch, user=None):
self.branch = branch
self.user = user
self.previous_branch = None
def __enter__(self):
self.previous_branch = get_current_branch()
set_current_branch(self.branch)
return self
def __exit__(self, exc_type, exc_val, exc_tb):
set_current_branch(self.previous_branch)
@receiver(post_create_historical_record)
def handle_history_record(sender, instance, history_instance, **kwargs):
"""Handle creation of historical records by adding them to changesets"""
# Only handle records from HistoricalModel subclasses
if not isinstance(instance, HistoricalModel):
return
branch = get_current_branch()
if not branch:
# If no branch is set, use the default branch
branch, _ = VersionBranch.objects.get_or_create(
name='main',
defaults={
'metadata': {
'type': 'default_branch',
'created_automatically': True
}
}
)
# Create or get active changeset for the current branch
changeset = getattr(_changeset_context, 'active_changeset', None)
if not changeset:
changeset = ChangeSet.objects.create(
branch=branch,
created_by=history_instance.history_user,
description=f"Automatic change tracking: {history_instance.history_type}",
metadata={
'auto_tracked': True,
'model': instance._meta.model_name,
'history_type': history_instance.history_type
},
status='applied'
)
_changeset_context.active_changeset = changeset
# Add the historical record to the changeset
changeset.historical_records.add(history_instance)
@receiver(post_save, sender=ChangeSet)
def handle_changeset_save(sender, instance, created, **kwargs):
"""Handle changeset creation by updating related objects"""
if created and instance.status == 'applied':
# Clear the active changeset if this is the one we were using
active_changeset = getattr(_changeset_context, 'active_changeset', None)
if active_changeset and active_changeset.id == instance.id:
delattr(_changeset_context, 'active_changeset')
# Update branch metadata
branch = instance.branch
if not branch.metadata.get('first_change'):
branch.metadata['first_change'] = instance.created_at.isoformat()
branch.metadata['last_change'] = instance.created_at.isoformat()
branch.metadata['change_count'] = branch.changesets.count()
branch.save()
def start_changeset(branch, user=None, description=None):
"""Start a new changeset in the given branch"""
changeset = ChangeSet.objects.create(
branch=branch,
created_by=user,
description=description or "Manual changeset",
status='pending'
)
_changeset_context.active_changeset = changeset
return changeset
def commit_changeset(success=True):
"""Commit the current changeset"""
changeset = getattr(_changeset_context, 'active_changeset', None)
if changeset:
changeset.status = 'applied' if success else 'failed'
changeset.save()
delattr(_changeset_context, 'active_changeset')
return changeset
class ChangesetManager:
"""Context manager for handling changesets"""
def __init__(self, branch, user=None, description=None):
self.branch = branch
self.user = user
self.description = description
self.changeset = None
def __enter__(self):
self.changeset = start_changeset(
self.branch,
self.user,
self.description
)
return self.changeset
def __exit__(self, exc_type, exc_val, exc_tb):
commit_changeset(success=exc_type is None)

View File

@@ -0,0 +1,58 @@
<div class="bg-white rounded-lg shadow p-4 mb-4">
<h3 class="text-lg font-semibold mb-4">Create New Branch</h3>
<form hx-post="{% url 'history:branch-create' %}"
hx-target="#branch-list"
hx-swap="afterbegin"
class="space-y-4">
{% csrf_token %}
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Branch Name
</label>
<input type="text" name="name" required
placeholder="feature/my-new-branch"
class="w-full px-3 py-2 border border-gray-300 rounded-md"
pattern="[a-zA-Z0-9/_-]+"
title="Only letters, numbers, underscores, forward slashes, and hyphens allowed">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Parent Branch
</label>
<select name="parent"
class="w-full px-3 py-2 border border-gray-300 rounded-md">
<option value="">-- Create from root --</option>
{% for branch in branches %}
<option value="{{ branch.name }}">{{ branch.name }}</option>
{% endfor %}
</select>
<p class="text-sm text-gray-500 mt-1">
Leave empty to create from root
</p>
</div>
<div class="flex justify-end space-x-3">
<button type="button"
class="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
onclick="document.getElementById('branch-form-container').innerHTML = ''">
Cancel
</button>
<button type="submit"
class="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600">
Create Branch
</button>
</div>
</form>
</div>
<script>
document.body.addEventListener('htmx:afterRequest', function(evt) {
if (evt.detail.successful && evt.detail.target.id === 'branch-list') {
document.getElementById('branch-form-container').innerHTML = '';
document.body.dispatchEvent(new CustomEvent('branch-updated'));
}
});
</script>

View File

@@ -0,0 +1,43 @@
<div class="branch-list space-y-2">
{% for branch in branches %}
<div class="branch-item p-2 bg-gray-50 rounded hover:bg-gray-100 cursor-pointer {% if branch.name == current_branch %}bg-blue-50 border-l-4 border-blue-500{% endif %}"
hx-get="{% url 'history:history-view' %}?branch={{ branch.name }}"
hx-target="#history-view"
hx-trigger="click"
onclick="selectBranch('{{ branch.name }}')">
<div class="flex justify-between items-center">
<span class="font-medium text-gray-700">{{ branch.name }}</span>
{% if branch.is_active %}
<span class="text-xs bg-green-100 text-green-800 px-2 py-1 rounded">Active</span>
{% endif %}
</div>
{% if branch.parent %}
<div class="text-xs text-gray-500 mt-1">
from: {{ branch.parent.name }}
</div>
{% endif %}
<div class="flex space-x-2 mt-2">
<button class="text-xs bg-blue-500 text-white px-2 py-1 rounded hover:bg-blue-600"
hx-get="{% url 'history:merge-view' %}?source={{ branch.name }}"
hx-target="#merge-panel"
hx-trigger="click">
Merge
</button>
<button class="text-xs bg-purple-500 text-white px-2 py-1 rounded hover:bg-purple-600"
hx-get="{% url 'history:tag-create' %}?branch={{ branch.name }}"
hx-target="#tag-form-container"
hx-trigger="click">
Tag
</button>
</div>
</div>
{% endfor %}
</div>
<script>
function selectBranch(name) {
document.body.dispatchEvent(new CustomEvent('branch-selected', {
detail: { branch: name }
}));
}
</script>

View File

@@ -0,0 +1,88 @@
<div class="history-view">
<h3 class="text-xl font-semibold mb-4">Change History</h3>
{% if changes %}
<div class="space-y-4">
{% for change in changes %}
<div class="change-item bg-gray-50 p-4 rounded-lg">
<div class="flex justify-between items-start">
<div>
<h4 class="font-medium text-gray-900">{{ change.description }}</h4>
<div class="text-sm text-gray-500 mt-1">
{{ change.created_at|date:"M d, Y H:i" }}
{% if change.created_by %}
by {{ change.created_by.username }}
{% endif %}
</div>
</div>
<span class="px-2 py-1 text-sm rounded
{% if change.status == 'applied' %}
bg-green-100 text-green-800
{% elif change.status == 'pending' %}
bg-yellow-100 text-yellow-800
{% elif change.status == 'failed' %}
bg-red-100 text-red-800
{% elif change.status == 'reverted' %}
bg-gray-100 text-gray-800
{% endif %}">
{{ change.status|title }}
</span>
</div>
{% if change.historical_records.exists %}
<div class="mt-3 space-y-2">
{% for record in change.historical_records.all %}
<div class="text-sm bg-white p-2 rounded border border-gray-200">
<div class="font-medium">
{{ record.instance_type|title }}
{% if record.history_type == '+' %}
created
{% elif record.history_type == '-' %}
deleted
{% else %}
modified
{% endif %}
</div>
{% if record.history_type == '~' and record.diff_to_prev %}
<div class="mt-1 text-gray-600">
Changes:
<ul class="list-disc list-inside">
{% for field, values in record.diff_to_prev.items %}
<li>{{ field }}: {{ values.old }} → {{ values.new }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
{% if change.metadata %}
<div class="mt-3 text-sm text-gray-600">
<details>
<summary class="cursor-pointer">Additional Details</summary>
<pre class="mt-2 bg-gray-100 p-2 rounded text-xs overflow-x-auto">{{ change.metadata|pprint }}</pre>
</details>
</div>
{% endif %}
{% if change.status == 'applied' %}
<div class="mt-3">
<button class="text-sm bg-red-500 text-white px-3 py-1 rounded hover:bg-red-600"
hx-post="{% url 'history:revert-change' change.id %}"
hx-confirm="Are you sure you want to revert this change?"
hx-target="#history-view">
Revert Change
</button>
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<div class="text-gray-500 text-center py-8">
No changes recorded for this branch yet.
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,116 @@
<div class="merge-conflicts bg-white rounded-lg shadow p-4">
<div class="bg-yellow-50 border-l-4 border-yellow-400 p-4 mb-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-yellow-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-lg font-medium text-yellow-800">
Merge Conflicts Detected
</h3>
<div class="mt-2 text-sm text-yellow-700">
<p>Conflicts were found while merging '{{ source.name }}' into '{{ target.name }}'.</p>
</div>
</div>
</div>
</div>
<div class="space-y-4">
<h4 class="font-medium text-gray-900">Conflicts to Resolve:</h4>
<form hx-post="{% url 'history:resolve-conflicts' %}"
hx-target="#merge-panel">
{% csrf_token %}
<input type="hidden" name="source" value="{{ source.name }}">
<input type="hidden" name="target" value="{{ target.name }}">
{% for conflict in conflicts %}
<div class="bg-gray-50 p-4 rounded-lg mb-4">
<div class="flex justify-between items-start mb-2">
<h5 class="font-medium text-gray-900">
Conflict #{{ forloop.counter }}
</h5>
<span class="text-sm text-gray-500">
Type: {{ conflict.type }}
</span>
</div>
<div class="text-sm text-gray-600 mb-3">
{{ conflict.description }}
</div>
{% if conflict.type == 'content_conflict' %}
<div class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Source Version ({{ source.name }})
</label>
<div class="bg-white p-2 rounded border border-gray-200">
<pre class="text-sm overflow-x-auto">{{ conflict.source_content }}</pre>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Target Version ({{ target.name }})
</label>
<div class="bg-white p-2 rounded border border-gray-200">
<pre class="text-sm overflow-x-auto">{{ conflict.target_content }}</pre>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Resolution
</label>
<select name="resolution_{{ conflict.source_change }}_{{ conflict.target_change }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md">
<option value="source">Use source version</option>
<option value="target">Use target version</option>
<option value="manual">Resolve manually</option>
</select>
</div>
<div class="manual-resolution hidden">
<label class="block text-sm font-medium text-gray-700 mb-1">
Manual Resolution
</label>
<textarea name="manual_{{ conflict.source_change }}_{{ conflict.target_change }}"
rows="4"
class="w-full px-3 py-2 border border-gray-300 rounded-md"
placeholder="Enter manual resolution here..."></textarea>
</div>
</div>
{% endif %}
</div>
{% endfor %}
<div class="flex justify-end space-x-3 mt-4">
<button type="button"
class="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
onclick="document.getElementById('merge-panel').innerHTML = ''">
Cancel
</button>
<button type="submit"
class="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600">
Apply Resolutions
</button>
</div>
</form>
</div>
</div>
<script>
document.addEventListener('change', function(e) {
if (e.target.tagName === 'SELECT' && e.target.name.startsWith('resolution_')) {
const manualDiv = e.target.parentElement.nextElementSibling;
if (e.target.value === 'manual') {
manualDiv.classList.remove('hidden');
} else {
manualDiv.classList.add('hidden');
}
}
});
</script>

View File

@@ -0,0 +1,49 @@
<div class="merge-panel">
<h3 class="text-xl font-semibold mb-4">Merge Branches</h3>
<form hx-post="{% url 'history:merge-view' %}"
hx-target="#merge-panel"
class="space-y-4">
{% csrf_token %}
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Source Branch
</label>
<input type="text" name="source" value="{{ source }}" readonly
class="w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-50">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Target Branch
</label>
<select name="target" required
class="w-full px-3 py-2 border border-gray-300 rounded-md">
<option value="">Select target branch...</option>
{% for branch in branches %}
{% if branch.name != source %}
<option value="{{ branch.name }}"
{% if branch.name == target %}selected{% endif %}>
{{ branch.name }}
</option>
{% endif %}
{% endfor %}
</select>
</div>
</div>
<div class="flex justify-end space-x-3">
<button type="button"
class="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
onclick="document.getElementById('merge-panel').innerHTML = ''">
Cancel
</button>
<button type="submit"
class="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600">
Start Merge
</button>
</div>
</form>
</div>

View File

@@ -0,0 +1,30 @@
<div class="merge-result bg-white rounded-lg shadow p-4">
<div class="bg-green-50 border-l-4 border-green-400 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-green-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-lg font-medium text-green-800">
Merge Successful
</h3>
<div class="mt-2 text-sm text-green-700">
<p>Successfully merged branch '{{ source.name }}' into '{{ target.name }}'.</p>
</div>
</div>
</div>
</div>
<div class="mt-4">
<button class="text-sm bg-gray-100 text-gray-700 px-3 py-1 rounded hover:bg-gray-200"
onclick="document.getElementById('merge-panel').innerHTML = ''">
Close
</button>
</div>
</div>
<script>
document.body.dispatchEvent(new CustomEvent('branch-updated'));
</script>

View File

@@ -0,0 +1,53 @@
{% extends "base.html" %}
{% block content %}
<div class="version-control-panel p-4">
<div class="grid grid-cols-12 gap-4">
<!-- Left Sidebar -->
<div class="col-span-3 bg-white rounded-lg shadow p-4">
<div class="mb-4">
<h3 class="text-lg font-semibold mb-2">Branches</h3>
<button
class="w-full bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
hx-get="{% url 'history:branch-create' %}"
hx-target="#branch-form-container"
>
New Branch
</button>
</div>
<!-- Branch List -->
<div
id="branch-list"
hx-get="{% url 'history:branch-list' %}"
hx-trigger="load, branch-updated from:body">
<!-- Branch list will be loaded here -->
</div>
</div>
<!-- Main Content Area -->
<div class="col-span-9">
<!-- Branch Form Container -->
<div id="branch-form-container"></div>
<!-- History View -->
<div
id="history-view"
class="bg-white rounded-lg shadow p-4 mb-4"
hx-get="{% url 'history:history-view' %}?branch={{ current_branch }}"
hx-trigger="load, branch-selected from:body">
<!-- History will be loaded here -->
</div>
<!-- Merge Panel -->
<div
id="merge-panel"
class="bg-white rounded-lg shadow p-4"
hx-get="{% url 'history:merge-view' %}"
hx-trigger="merge-initiated from:body">
<!-- Merge interface will be loaded here -->
</div>
</div>
</div>
</div>
{% endblock %}

22
history_tracking/urls.py Normal file
View File

@@ -0,0 +1,22 @@
from django.urls import path
from . import views
app_name = 'history'
urlpatterns = [
# Main VCS interface
path('vcs/', views.VersionControlPanel.as_view(), name='vcs-panel'),
# Branch operations
path('vcs/branches/', views.BranchListView.as_view(), name='branch-list'),
path('vcs/branches/create/', views.BranchCreateView.as_view(), name='branch-create'),
# History views
path('vcs/history/', views.HistoryView.as_view(), name='history-view'),
# Merge operations
path('vcs/merge/', views.MergeView.as_view(), name='merge-view'),
# Tag operations
path('vcs/tags/create/', views.TagCreateView.as_view(), name='tag-create'),
]

139
history_tracking/utils.py Normal file
View File

@@ -0,0 +1,139 @@
from typing import Dict, Any, List, Optional
from django.core.exceptions import ValidationError
from .models import VersionBranch, ChangeSet
from django.utils import timezone
from django.contrib.auth import get_user_model
User = get_user_model()
def resolve_conflicts(
source_branch: VersionBranch,
target_branch: VersionBranch,
resolutions: Dict[str, str],
manual_resolutions: Dict[str, str],
user: Optional[User] = None
) -> ChangeSet:
"""
Resolve merge conflicts between branches
Args:
source_branch: Source branch of the merge
target_branch: Target branch of the merge
resolutions: Dict mapping conflict IDs to resolution type ('source', 'target', 'manual')
manual_resolutions: Dict mapping conflict IDs to manual resolution content
user: User performing the resolution
Returns:
ChangeSet: The changeset recording the conflict resolution
"""
if not resolutions:
raise ValidationError("No resolutions provided")
resolved_content = {}
for conflict_id, resolution_type in resolutions.items():
source_id, target_id = conflict_id.split('_')
source_change = ChangeSet.objects.get(pk=source_id)
target_change = ChangeSet.objects.get(pk=target_id)
if resolution_type == 'source':
# Use source branch version
for record in source_change.historical_records.all():
resolved_content[f"{record.instance_type}_{record.instance_pk}"] = record
elif resolution_type == 'target':
# Use target branch version
for record in target_change.historical_records.all():
resolved_content[f"{record.instance_type}_{record.instance_pk}"] = record
elif resolution_type == 'manual':
# Use manual resolution
manual_content = manual_resolutions.get(conflict_id)
if not manual_content:
raise ValidationError(f"Manual resolution missing for conflict {conflict_id}")
# Create new historical record with manual content
base_record = source_change.historical_records.first()
if base_record:
new_record = base_record.__class__(
**{
**base_record.__dict__,
'id': base_record.id,
'history_date': timezone.now(),
'history_user': user,
'history_change_reason': 'Manual conflict resolution',
'history_type': '~'
}
)
# Apply manual changes
for field, value in manual_content.items():
setattr(new_record, field, value)
resolved_content[f"{new_record.instance_type}_{new_record.instance_pk}"] = new_record
# Create resolution changeset
resolution_changeset = ChangeSet.objects.create(
branch=target_branch,
created_by=user,
description=f"Resolved conflicts from '{source_branch.name}'",
metadata={
'resolution_type': 'conflict_resolution',
'source_branch': source_branch.name,
'resolved_conflicts': list(resolutions.keys())
},
status='applied'
)
# Add resolved records to changeset
for record in resolved_content.values():
resolution_changeset.historical_records.add(record)
return resolution_changeset
def get_change_diff(change: ChangeSet) -> List[Dict[str, Any]]:
"""
Get a structured diff of changes in a changeset
Args:
change: The changeset to analyze
Returns:
List of diffs for each changed record
"""
diffs = []
for record in change.historical_records.all():
diff = {
'model': record.instance_type.__name__,
'id': record.instance_pk,
'type': record.history_type,
'date': record.history_date,
'user': record.history_user_display,
'changes': {}
}
if record.history_type == '~': # Modified
previous = record.prev_record
if previous:
diff['changes'] = record.diff_against_previous
elif record.history_type == '+': # Added
diff['changes'] = {
field: {'old': None, 'new': str(getattr(record, field))}
for field in record.__dict__
if not field.startswith('_') and field not in [
'history_date', 'history_id', 'history_type',
'history_user_id', 'history_change_reason'
]
}
elif record.history_type == '-': # Deleted
diff['changes'] = {
field: {'old': str(getattr(record, field)), 'new': None}
for field in record.__dict__
if not field.startswith('_') and field not in [
'history_date', 'history_id', 'history_type',
'history_user_id', 'history_change_reason'
]
}
diffs.append(diff)
return diffs

View File

@@ -1,3 +1,237 @@
from django.shortcuts import render
from django.views.generic import TemplateView, View
from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404
from django.template.loader import render_to_string
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import ValidationError
from django.db import transaction
# Create your views here.
from .models import VersionBranch, VersionTag, ChangeSet
from .managers import BranchManager, ChangeTracker, MergeStrategy
class VersionControlPanel(LoginRequiredMixin, TemplateView):
"""Main version control interface"""
template_name = 'history_tracking/version_control_panel.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
branch_manager = BranchManager()
context.update({
'branches': branch_manager.list_branches(),
'current_branch': self.request.GET.get('branch'),
})
return context
class BranchListView(LoginRequiredMixin, View):
"""HTMX view for branch list"""
def get(self, request):
branch_manager = BranchManager()
branches = branch_manager.list_branches()
content = render_to_string(
'history_tracking/components/branch_list.html',
{'branches': branches},
request=request
)
return HttpResponse(content)
class HistoryView(LoginRequiredMixin, View):
"""HTMX view for change history"""
def get(self, request):
branch_name = request.GET.get('branch')
if not branch_name:
return HttpResponse("No branch selected")
branch = get_object_or_404(VersionBranch, name=branch_name)
tracker = ChangeTracker()
changes = tracker.get_changes(branch)
content = render_to_string(
'history_tracking/components/history_view.html',
{'changes': changes},
request=request
)
return HttpResponse(content)
class MergeView(LoginRequiredMixin, View):
"""HTMX view for merge operations"""
def get(self, request):
source = request.GET.get('source')
target = request.GET.get('target')
if not (source and target):
return HttpResponse("Source and target branches required")
content = render_to_string(
'history_tracking/components/merge_panel.html',
{
'source': source,
'target': target
},
request=request
)
return HttpResponse(content)
@transaction.atomic
def post(self, request):
source_name = request.POST.get('source')
target_name = request.POST.get('target')
if not (source_name and target_name):
return JsonResponse({
'error': 'Source and target branches required'
}, status=400)
try:
source = get_object_or_404(VersionBranch, name=source_name)
target = get_object_or_404(VersionBranch, name=target_name)
branch_manager = BranchManager()
success, conflicts = branch_manager.merge_branches(
source=source,
target=target,
user=request.user
)
if success:
content = render_to_string(
'history_tracking/components/merge_success.html',
{'source': source, 'target': target},
request=request
)
return HttpResponse(content)
else:
content = render_to_string(
'history_tracking/components/merge_conflicts.html',
{
'source': source,
'target': target,
'conflicts': conflicts
},
request=request
)
return HttpResponse(content)
except ValidationError as e:
return JsonResponse({'error': str(e)}, status=400)
except Exception as e:
return JsonResponse(
{'error': 'Merge failed. Please try again.'},
status=500
)
class BranchCreateView(LoginRequiredMixin, View):
"""HTMX view for branch creation"""
def get(self, request):
content = render_to_string(
'history_tracking/components/branch_create.html',
request=request
)
return HttpResponse(content)
@transaction.atomic
def post(self, request):
name = request.POST.get('name')
parent_name = request.POST.get('parent')
if not name:
return JsonResponse({'error': 'Branch name required'}, status=400)
try:
branch_manager = BranchManager()
parent = None
if parent_name:
parent = get_object_or_404(VersionBranch, name=parent_name)
branch = branch_manager.create_branch(
name=name,
parent=parent,
user=request.user
)
content = render_to_string(
'history_tracking/components/branch_item.html',
{'branch': branch},
request=request
)
return HttpResponse(content)
except ValidationError as e:
return JsonResponse({'error': str(e)}, status=400)
except Exception as e:
return JsonResponse(
{'error': 'Branch creation failed. Please try again.'},
status=500
)
class TagCreateView(LoginRequiredMixin, View):
"""HTMX view for version tagging"""
def get(self, request):
branch_name = request.GET.get('branch')
if not branch_name:
return HttpResponse("Branch required")
content = render_to_string(
'history_tracking/components/tag_create.html',
{'branch_name': branch_name},
request=request
)
return HttpResponse(content)
@transaction.atomic
def post(self, request):
name = request.POST.get('name')
branch_name = request.POST.get('branch')
if not (name and branch_name):
return JsonResponse(
{'error': 'Tag name and branch required'},
status=400
)
try:
branch = get_object_or_404(VersionBranch, name=branch_name)
# Get latest historical record for the branch
latest_change = ChangeSet.objects.filter(
branch=branch,
status='applied'
).latest('created_at')
if not latest_change:
return JsonResponse(
{'error': 'No changes to tag'},
status=400
)
tag = VersionTag.objects.create(
name=name,
branch=branch,
historical_record=latest_change.historical_records.latest('history_date'),
created_by=request.user,
metadata={
'tagged_at': timezone.now().isoformat(),
'changeset': latest_change.pk
}
)
content = render_to_string(
'history_tracking/components/tag_item.html',
{'tag': tag},
request=request
)
return HttpResponse(content)
except ValidationError as e:
return JsonResponse({'error': str(e)}, status=400)
except Exception as e:
return JsonResponse(
{'error': 'Tag creation failed. Please try again.'},
status=500
)