FossilRepo

Add repo lifecycle UI: clone on create, project settings tab Project creation now offers repo source options: create empty (default) or clone from a Fossil URL. Clone runs synchronously via fossil CLI, sets remote URL on the FossilRepository record. New Settings tab (admin-only) per project showing: repo info (filename, size, project code, checkin/ticket/wiki counts), remote URL management, HTTP clone URL, sync metadata and pull buttons, danger zone placeholder. Signal guard: skip fossil init if .fossil file already exists on disk (prevents overwriting cloned repos). 25 tests covering form rendering, creation flows, settings access control, settings actions, and signal behavior.

lmata 2026-04-07 14:15 trunk
Commit aa2d79b915605915dbad593ecb6b960bc5f4cab831b35d3132d7b2d7f34eb634
+17 -14
--- fossil/signals.py
+++ fossil/signals.py
@@ -26,19 +26,22 @@
2626
project=instance,
2727
filename=filename,
2828
created_by=instance.created_by,
2929
)
3030
31
- # Try to init the .fossil file on disk
32
- try:
33
- from fossil.cli import FossilCLI
34
-
35
- cli = FossilCLI()
36
- if cli.is_available():
37
- cli.init(repo.full_path)
38
- repo.file_size_bytes = repo.full_path.stat().st_size if repo.exists_on_disk else 0
39
- repo.save(update_fields=["file_size_bytes", "updated_at", "version"])
40
- logger.info("Created fossil repo: %s", repo.full_path)
41
- else:
42
- logger.warning("Fossil binary not available — repo record created but .fossil file not initialized")
43
- except Exception:
44
- logger.exception("Failed to init fossil repo: %s", filename)
31
+ # Try to init the .fossil file on disk (skip if file already exists, e.g. from a clone)
32
+ if not repo.full_path.exists():
33
+ try:
34
+ from fossil.cli import FossilCLI
35
+
36
+ cli = FossilCLI()
37
+ if cli.is_available():
38
+ cli.init(repo.full_path)
39
+ repo.file_size_bytes = repo.full_path.stat().st_size if repo.exists_on_disk else 0
40
+ repo.save(update_fields=["file_size_bytes", "updated_at", "version"])
41
+ logger.info("Created fossil repo: %s", repo.full_path)
42
+ else:
43
+ logger.warning("Fossil binary not available — repo record created but .fossil file not initialized")
44
+ except Exception:
45
+ logger.exception("Failed to init fossil repo: %s", filename)
46
+ else:
47
+ logger.info("Fossil file already exists, skipping init: %s", repo.full_path)
4548
--- fossil/signals.py
+++ fossil/signals.py
@@ -26,19 +26,22 @@
26 project=instance,
27 filename=filename,
28 created_by=instance.created_by,
29 )
30
31 # Try to init the .fossil file on disk
32 try:
33 from fossil.cli import FossilCLI
34
35 cli = FossilCLI()
36 if cli.is_available():
37 cli.init(repo.full_path)
38 repo.file_size_bytes = repo.full_path.stat().st_size if repo.exists_on_disk else 0
39 repo.save(update_fields=["file_size_bytes", "updated_at", "version"])
40 logger.info("Created fossil repo: %s", repo.full_path)
41 else:
42 logger.warning("Fossil binary not available — repo record created but .fossil file not initialized")
43 except Exception:
44 logger.exception("Failed to init fossil repo: %s", filename)
 
 
 
45
--- fossil/signals.py
+++ fossil/signals.py
@@ -26,19 +26,22 @@
26 project=instance,
27 filename=filename,
28 created_by=instance.created_by,
29 )
30
31 # Try to init the .fossil file on disk (skip if file already exists, e.g. from a clone)
32 if not repo.full_path.exists():
33 try:
34 from fossil.cli import FossilCLI
35
36 cli = FossilCLI()
37 if cli.is_available():
38 cli.init(repo.full_path)
39 repo.file_size_bytes = repo.full_path.stat().st_size if repo.exists_on_disk else 0
40 repo.save(update_fields=["file_size_bytes", "updated_at", "version"])
41 logger.info("Created fossil repo: %s", repo.full_path)
42 else:
43 logger.warning("Fossil binary not available — repo record created but .fossil file not initialized")
44 except Exception:
45 logger.exception("Failed to init fossil repo: %s", filename)
46 else:
47 logger.info("Fossil file already exists, skipping init: %s", repo.full_path)
48
--- fossil/urls.py
+++ fossil/urls.py
@@ -34,10 +34,11 @@
3434
path("tags/", views.tag_list, name="tags"),
3535
path("technotes/", views.technote_list, name="technotes"),
3636
path("search/", views.search, name="search"),
3737
path("stats/", views.repo_stats, name="stats"),
3838
path("compare/", views.compare_checkins, name="compare"),
39
+ path("settings/", views.repo_settings, name="repo_settings"),
3940
path("sync/", views.sync_pull, name="sync"),
4041
path("sync/git/", views.git_mirror_config, name="git_mirror"),
4142
path("sync/git/<int:mirror_id>/run/", views.git_mirror_run, name="git_mirror_run"),
4243
path("sync/git/connect/github/", views.oauth_github_start, name="oauth_github"),
4344
path("sync/git/connect/gitlab/", views.oauth_gitlab_start, name="oauth_gitlab"),
4445
--- fossil/urls.py
+++ fossil/urls.py
@@ -34,10 +34,11 @@
34 path("tags/", views.tag_list, name="tags"),
35 path("technotes/", views.technote_list, name="technotes"),
36 path("search/", views.search, name="search"),
37 path("stats/", views.repo_stats, name="stats"),
38 path("compare/", views.compare_checkins, name="compare"),
 
39 path("sync/", views.sync_pull, name="sync"),
40 path("sync/git/", views.git_mirror_config, name="git_mirror"),
41 path("sync/git/<int:mirror_id>/run/", views.git_mirror_run, name="git_mirror_run"),
42 path("sync/git/connect/github/", views.oauth_github_start, name="oauth_github"),
43 path("sync/git/connect/gitlab/", views.oauth_gitlab_start, name="oauth_gitlab"),
44
--- fossil/urls.py
+++ fossil/urls.py
@@ -34,10 +34,11 @@
34 path("tags/", views.tag_list, name="tags"),
35 path("technotes/", views.technote_list, name="technotes"),
36 path("search/", views.search, name="search"),
37 path("stats/", views.repo_stats, name="stats"),
38 path("compare/", views.compare_checkins, name="compare"),
39 path("settings/", views.repo_settings, name="repo_settings"),
40 path("sync/", views.sync_pull, name="sync"),
41 path("sync/git/", views.git_mirror_config, name="git_mirror"),
42 path("sync/git/<int:mirror_id>/run/", views.git_mirror_run, name="git_mirror_run"),
43 path("sync/git/connect/github/", views.oauth_github_start, name="oauth_github"),
44 path("sync/git/connect/gitlab/", views.oauth_gitlab_start, name="oauth_gitlab"),
45
--- fossil/views.py
+++ fossil/views.py
@@ -1420,10 +1420,117 @@
14201420
"result": result,
14211421
"active_tab": "sync",
14221422
},
14231423
)
14241424
1425
+
1426
+# --- Repository Settings ---
1427
+
1428
+
1429
+@login_required
1430
+def repo_settings(request, slug):
1431
+ """Repository settings: remote URL, storage info, danger zone."""
1432
+ from projects.access import require_project_admin
1433
+
1434
+ project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
1435
+ require_project_admin(request, project)
1436
+ fossil_repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True)
1437
+
1438
+ if request.method == "POST":
1439
+ action = request.POST.get("action", "")
1440
+
1441
+ if action == "update_remote":
1442
+ remote_url = request.POST.get("remote_url", "").strip()
1443
+ fossil_repo.remote_url = remote_url
1444
+ fossil_repo.save(update_fields=["remote_url", "updated_at", "version"])
1445
+ from django.contrib import messages
1446
+
1447
+ messages.success(request, "Remote URL updated.")
1448
+
1449
+ elif action == "sync_metadata":
1450
+ # Refresh metadata from the .fossil file
1451
+ if fossil_repo.exists_on_disk:
1452
+ with contextlib.suppress(Exception), FossilReader(fossil_repo.full_path) as reader:
1453
+ meta = reader.get_metadata()
1454
+ fossil_repo.checkin_count = meta.checkin_count
1455
+ fossil_repo.fossil_project_code = meta.project_code
1456
+ fossil_repo.file_size_bytes = fossil_repo.full_path.stat().st_size
1457
+ fossil_repo.save(
1458
+ update_fields=[
1459
+ "checkin_count",
1460
+ "fossil_project_code",
1461
+ "file_size_bytes",
1462
+ "updated_at",
1463
+ "version",
1464
+ ]
1465
+ )
1466
+ from django.contrib import messages
1467
+
1468
+ messages.success(request, "Metadata synced from repository file.")
1469
+
1470
+ elif action == "pull_remote":
1471
+ if fossil_repo.remote_url and fossil_repo.exists_on_disk:
1472
+ from fossil.cli import FossilCLI
1473
+
1474
+ cli = FossilCLI()
1475
+ if cli.is_available():
1476
+ cli.ensure_default_user(fossil_repo.full_path)
1477
+ result = cli.pull(fossil_repo.full_path)
1478
+ from django.contrib import messages
1479
+
1480
+ if result["success"]:
1481
+ from django.utils import timezone
1482
+
1483
+ fossil_repo.last_sync_at = timezone.now()
1484
+ if result["artifacts_received"] > 0:
1485
+ with FossilReader(fossil_repo.full_path) as rdr:
1486
+ fossil_repo.checkin_count = rdr.get_checkin_count()
1487
+ fossil_repo.file_size_bytes = fossil_repo.full_path.stat().st_size
1488
+ fossil_repo.save(
1489
+ update_fields=[
1490
+ "last_sync_at",
1491
+ "checkin_count",
1492
+ "file_size_bytes",
1493
+ "updated_at",
1494
+ "version",
1495
+ ]
1496
+ )
1497
+ if result["artifacts_received"] > 0:
1498
+ messages.success(request, f"Pulled {result['artifacts_received']} new artifacts.")
1499
+ else:
1500
+ messages.info(request, "Already up to date.")
1501
+ else:
1502
+ messages.warning(request, f"Pull failed: {result['message']}")
1503
+
1504
+ return redirect("fossil:repo_settings", slug=slug)
1505
+
1506
+ # Gather repo info for display
1507
+ repo_info = {
1508
+ "exists_on_disk": fossil_repo.exists_on_disk,
1509
+ }
1510
+ if fossil_repo.exists_on_disk:
1511
+ repo_info["file_size"] = fossil_repo.full_path.stat().st_size
1512
+ repo_info["file_path"] = str(fossil_repo.full_path)
1513
+ with contextlib.suppress(Exception), FossilReader(fossil_repo.full_path) as reader:
1514
+ meta = reader.get_metadata()
1515
+ repo_info["project_name"] = meta.project_name
1516
+ repo_info["project_code"] = meta.project_code
1517
+ repo_info["checkin_count"] = meta.checkin_count
1518
+ repo_info["ticket_count"] = meta.ticket_count
1519
+ repo_info["wiki_page_count"] = meta.wiki_page_count
1520
+
1521
+ return render(
1522
+ request,
1523
+ "fossil/repo_settings.html",
1524
+ {
1525
+ "project": project,
1526
+ "fossil_repo": fossil_repo,
1527
+ "repo_info": repo_info,
1528
+ "active_tab": "settings",
1529
+ },
1530
+ )
1531
+
14251532
14261533
# --- Git Mirror ---
14271534
14281535
14291536
@login_required
14301537
--- fossil/views.py
+++ fossil/views.py
@@ -1420,10 +1420,117 @@
1420 "result": result,
1421 "active_tab": "sync",
1422 },
1423 )
1424
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1425
1426 # --- Git Mirror ---
1427
1428
1429 @login_required
1430
--- fossil/views.py
+++ fossil/views.py
@@ -1420,10 +1420,117 @@
1420 "result": result,
1421 "active_tab": "sync",
1422 },
1423 )
1424
1425
1426 # --- Repository Settings ---
1427
1428
1429 @login_required
1430 def repo_settings(request, slug):
1431 """Repository settings: remote URL, storage info, danger zone."""
1432 from projects.access import require_project_admin
1433
1434 project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
1435 require_project_admin(request, project)
1436 fossil_repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True)
1437
1438 if request.method == "POST":
1439 action = request.POST.get("action", "")
1440
1441 if action == "update_remote":
1442 remote_url = request.POST.get("remote_url", "").strip()
1443 fossil_repo.remote_url = remote_url
1444 fossil_repo.save(update_fields=["remote_url", "updated_at", "version"])
1445 from django.contrib import messages
1446
1447 messages.success(request, "Remote URL updated.")
1448
1449 elif action == "sync_metadata":
1450 # Refresh metadata from the .fossil file
1451 if fossil_repo.exists_on_disk:
1452 with contextlib.suppress(Exception), FossilReader(fossil_repo.full_path) as reader:
1453 meta = reader.get_metadata()
1454 fossil_repo.checkin_count = meta.checkin_count
1455 fossil_repo.fossil_project_code = meta.project_code
1456 fossil_repo.file_size_bytes = fossil_repo.full_path.stat().st_size
1457 fossil_repo.save(
1458 update_fields=[
1459 "checkin_count",
1460 "fossil_project_code",
1461 "file_size_bytes",
1462 "updated_at",
1463 "version",
1464 ]
1465 )
1466 from django.contrib import messages
1467
1468 messages.success(request, "Metadata synced from repository file.")
1469
1470 elif action == "pull_remote":
1471 if fossil_repo.remote_url and fossil_repo.exists_on_disk:
1472 from fossil.cli import FossilCLI
1473
1474 cli = FossilCLI()
1475 if cli.is_available():
1476 cli.ensure_default_user(fossil_repo.full_path)
1477 result = cli.pull(fossil_repo.full_path)
1478 from django.contrib import messages
1479
1480 if result["success"]:
1481 from django.utils import timezone
1482
1483 fossil_repo.last_sync_at = timezone.now()
1484 if result["artifacts_received"] > 0:
1485 with FossilReader(fossil_repo.full_path) as rdr:
1486 fossil_repo.checkin_count = rdr.get_checkin_count()
1487 fossil_repo.file_size_bytes = fossil_repo.full_path.stat().st_size
1488 fossil_repo.save(
1489 update_fields=[
1490 "last_sync_at",
1491 "checkin_count",
1492 "file_size_bytes",
1493 "updated_at",
1494 "version",
1495 ]
1496 )
1497 if result["artifacts_received"] > 0:
1498 messages.success(request, f"Pulled {result['artifacts_received']} new artifacts.")
1499 else:
1500 messages.info(request, "Already up to date.")
1501 else:
1502 messages.warning(request, f"Pull failed: {result['message']}")
1503
1504 return redirect("fossil:repo_settings", slug=slug)
1505
1506 # Gather repo info for display
1507 repo_info = {
1508 "exists_on_disk": fossil_repo.exists_on_disk,
1509 }
1510 if fossil_repo.exists_on_disk:
1511 repo_info["file_size"] = fossil_repo.full_path.stat().st_size
1512 repo_info["file_path"] = str(fossil_repo.full_path)
1513 with contextlib.suppress(Exception), FossilReader(fossil_repo.full_path) as reader:
1514 meta = reader.get_metadata()
1515 repo_info["project_name"] = meta.project_name
1516 repo_info["project_code"] = meta.project_code
1517 repo_info["checkin_count"] = meta.checkin_count
1518 repo_info["ticket_count"] = meta.ticket_count
1519 repo_info["wiki_page_count"] = meta.wiki_page_count
1520
1521 return render(
1522 request,
1523 "fossil/repo_settings.html",
1524 {
1525 "project": project,
1526 "fossil_repo": fossil_repo,
1527 "repo_info": repo_info,
1528 "active_tab": "settings",
1529 },
1530 )
1531
1532
1533 # --- Git Mirror ---
1534
1535
1536 @login_required
1537
--- projects/forms.py
+++ projects/forms.py
@@ -4,21 +4,46 @@
44
55
from .models import Project, ProjectTeam
66
77
tw = "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"
88
9
+REPO_SOURCE_CHOICES = [
10
+ ("empty", "Create empty repository"),
11
+ ("fossil_url", "Clone from Fossil URL"),
12
+]
13
+
914
1015
class ProjectForm(forms.ModelForm):
16
+ repo_source = forms.ChoiceField(
17
+ choices=REPO_SOURCE_CHOICES,
18
+ initial="empty",
19
+ widget=forms.RadioSelect,
20
+ required=False,
21
+ )
22
+ clone_url = forms.URLField(
23
+ required=False,
24
+ widget=forms.URLInput(attrs={"placeholder": "https://fossil-scm.org/home"}),
25
+ help_text="Fossil repository URL to clone from",
26
+ )
27
+
1128
class Meta:
1229
model = Project
1330
fields = ["name", "description", "visibility"]
1431
widgets = {
1532
"name": forms.TextInput(attrs={"class": tw, "placeholder": "Project name"}),
1633
"description": forms.Textarea(attrs={"class": tw, "rows": 3, "placeholder": "Description"}),
1734
"visibility": forms.Select(attrs={"class": tw}),
1835
}
1936
37
+ def clean(self):
38
+ cleaned = super().clean()
39
+ repo_source = cleaned.get("repo_source", "empty")
40
+ clone_url = cleaned.get("clone_url", "").strip()
41
+ if repo_source == "fossil_url" and not clone_url:
42
+ self.add_error("clone_url", "Clone URL is required when cloning from a Fossil URL.")
43
+ return cleaned
44
+
2045
2146
class ProjectTeamAddForm(forms.Form):
2247
team = forms.ModelChoiceField(
2348
queryset=Team.objects.none(),
2449
widget=forms.Select(attrs={"class": tw}),
2550
--- projects/forms.py
+++ projects/forms.py
@@ -4,21 +4,46 @@
4
5 from .models import Project, ProjectTeam
6
7 tw = "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"
8
 
 
 
 
 
9
10 class ProjectForm(forms.ModelForm):
 
 
 
 
 
 
 
 
 
 
 
 
11 class Meta:
12 model = Project
13 fields = ["name", "description", "visibility"]
14 widgets = {
15 "name": forms.TextInput(attrs={"class": tw, "placeholder": "Project name"}),
16 "description": forms.Textarea(attrs={"class": tw, "rows": 3, "placeholder": "Description"}),
17 "visibility": forms.Select(attrs={"class": tw}),
18 }
19
 
 
 
 
 
 
 
 
20
21 class ProjectTeamAddForm(forms.Form):
22 team = forms.ModelChoiceField(
23 queryset=Team.objects.none(),
24 widget=forms.Select(attrs={"class": tw}),
25
--- projects/forms.py
+++ projects/forms.py
@@ -4,21 +4,46 @@
4
5 from .models import Project, ProjectTeam
6
7 tw = "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"
8
9 REPO_SOURCE_CHOICES = [
10 ("empty", "Create empty repository"),
11 ("fossil_url", "Clone from Fossil URL"),
12 ]
13
14
15 class ProjectForm(forms.ModelForm):
16 repo_source = forms.ChoiceField(
17 choices=REPO_SOURCE_CHOICES,
18 initial="empty",
19 widget=forms.RadioSelect,
20 required=False,
21 )
22 clone_url = forms.URLField(
23 required=False,
24 widget=forms.URLInput(attrs={"placeholder": "https://fossil-scm.org/home"}),
25 help_text="Fossil repository URL to clone from",
26 )
27
28 class Meta:
29 model = Project
30 fields = ["name", "description", "visibility"]
31 widgets = {
32 "name": forms.TextInput(attrs={"class": tw, "placeholder": "Project name"}),
33 "description": forms.Textarea(attrs={"class": tw, "rows": 3, "placeholder": "Description"}),
34 "visibility": forms.Select(attrs={"class": tw}),
35 }
36
37 def clean(self):
38 cleaned = super().clean()
39 repo_source = cleaned.get("repo_source", "empty")
40 clone_url = cleaned.get("clone_url", "").strip()
41 if repo_source == "fossil_url" and not clone_url:
42 self.add_error("clone_url", "Clone URL is required when cloning from a Fossil URL.")
43 return cleaned
44
45
46 class ProjectTeamAddForm(forms.Form):
47 team = forms.ModelChoiceField(
48 queryset=Team.objects.none(),
49 widget=forms.Select(attrs={"class": tw}),
50
--- projects/views.py
+++ projects/views.py
@@ -36,17 +36,64 @@
3636
if form.is_valid():
3737
project = form.save(commit=False)
3838
project.organization = org
3939
project.created_by = request.user
4040
project.save()
41
+
42
+ # Handle repo source: clone from URL if requested
43
+ repo_source = form.cleaned_data.get("repo_source", "empty")
44
+ clone_url = form.cleaned_data.get("clone_url", "").strip()
45
+
46
+ if repo_source == "fossil_url" and clone_url:
47
+ _clone_fossil_repo(request, project, clone_url)
48
+
4149
messages.success(request, f'Project "{project.name}" created.')
4250
return redirect("projects:detail", slug=project.slug)
4351
else:
4452
form = ProjectForm()
4553
4654
return render(request, "projects/project_form.html", {"form": form, "title": "New Project"})
4755
56
+
57
+def _clone_fossil_repo(request, project, clone_url):
58
+ """Clone a Fossil repo from a remote URL, replacing the empty file created by the signal."""
59
+ import subprocess
60
+
61
+ from fossil.cli import FossilCLI
62
+ from fossil.models import FossilRepository
63
+
64
+ fossil_repo = FossilRepository.objects.filter(project=project).first()
65
+ if not fossil_repo:
66
+ return
67
+
68
+ cli = FossilCLI()
69
+ if not cli.is_available():
70
+ messages.warning(request, "Fossil binary not available -- clone skipped.")
71
+ return
72
+
73
+ # Remove the empty file created by the signal so we can clone into that path
74
+ if fossil_repo.full_path.exists():
75
+ fossil_repo.full_path.unlink()
76
+
77
+ try:
78
+ result = subprocess.run(
79
+ [cli.binary, "clone", clone_url, str(fossil_repo.full_path)],
80
+ capture_output=True,
81
+ text=True,
82
+ timeout=120,
83
+ env=cli._env,
84
+ )
85
+ if result.returncode == 0:
86
+ fossil_repo.remote_url = clone_url
87
+ fossil_repo.file_size_bytes = fossil_repo.full_path.stat().st_size if fossil_repo.exists_on_disk else 0
88
+ fossil_repo.save(update_fields=["remote_url", "file_size_bytes", "updated_at", "version"])
89
+ messages.success(request, f"Repository cloned from {clone_url}")
90
+ else:
91
+ messages.warning(request, f"Clone failed: {result.stderr.strip()}")
92
+ except subprocess.TimeoutExpired:
93
+ messages.warning(request, "Clone timed out -- the repository may be large. Try pulling later.")
94
+
4895
4996
@login_required
5097
def project_detail(request, slug):
5198
P.PROJECT_VIEW.check(request.user)
5299
project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
53100
--- projects/views.py
+++ projects/views.py
@@ -36,17 +36,64 @@
36 if form.is_valid():
37 project = form.save(commit=False)
38 project.organization = org
39 project.created_by = request.user
40 project.save()
 
 
 
 
 
 
 
 
41 messages.success(request, f'Project "{project.name}" created.')
42 return redirect("projects:detail", slug=project.slug)
43 else:
44 form = ProjectForm()
45
46 return render(request, "projects/project_form.html", {"form": form, "title": "New Project"})
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
49 @login_required
50 def project_detail(request, slug):
51 P.PROJECT_VIEW.check(request.user)
52 project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
53
--- projects/views.py
+++ projects/views.py
@@ -36,17 +36,64 @@
36 if form.is_valid():
37 project = form.save(commit=False)
38 project.organization = org
39 project.created_by = request.user
40 project.save()
41
42 # Handle repo source: clone from URL if requested
43 repo_source = form.cleaned_data.get("repo_source", "empty")
44 clone_url = form.cleaned_data.get("clone_url", "").strip()
45
46 if repo_source == "fossil_url" and clone_url:
47 _clone_fossil_repo(request, project, clone_url)
48
49 messages.success(request, f'Project "{project.name}" created.')
50 return redirect("projects:detail", slug=project.slug)
51 else:
52 form = ProjectForm()
53
54 return render(request, "projects/project_form.html", {"form": form, "title": "New Project"})
55
56
57 def _clone_fossil_repo(request, project, clone_url):
58 """Clone a Fossil repo from a remote URL, replacing the empty file created by the signal."""
59 import subprocess
60
61 from fossil.cli import FossilCLI
62 from fossil.models import FossilRepository
63
64 fossil_repo = FossilRepository.objects.filter(project=project).first()
65 if not fossil_repo:
66 return
67
68 cli = FossilCLI()
69 if not cli.is_available():
70 messages.warning(request, "Fossil binary not available -- clone skipped.")
71 return
72
73 # Remove the empty file created by the signal so we can clone into that path
74 if fossil_repo.full_path.exists():
75 fossil_repo.full_path.unlink()
76
77 try:
78 result = subprocess.run(
79 [cli.binary, "clone", clone_url, str(fossil_repo.full_path)],
80 capture_output=True,
81 text=True,
82 timeout=120,
83 env=cli._env,
84 )
85 if result.returncode == 0:
86 fossil_repo.remote_url = clone_url
87 fossil_repo.file_size_bytes = fossil_repo.full_path.stat().st_size if fossil_repo.exists_on_disk else 0
88 fossil_repo.save(update_fields=["remote_url", "file_size_bytes", "updated_at", "version"])
89 messages.success(request, f"Repository cloned from {clone_url}")
90 else:
91 messages.warning(request, f"Clone failed: {result.stderr.strip()}")
92 except subprocess.TimeoutExpired:
93 messages.warning(request, "Clone timed out -- the repository may be large. Try pulling later.")
94
95
96 @login_required
97 def project_detail(request, slug):
98 P.PROJECT_VIEW.check(request.user)
99 project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
100
--- templates/fossil/_project_nav.html
+++ templates/fossil/_project_nav.html
@@ -33,8 +33,12 @@
3333
</a>
3434
{% if perms.projects.change_project %}
3535
<a href="{% url 'fossil:sync' slug=project.slug %}"
3636
class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'sync' %}bg-gray-800 text-gray-100 border-b-2 border-brand{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}">
3737
{% if fossil_repo.remote_url %}Sync{% else %}Setup Sync{% endif %}
38
+ </a>
39
+ <a href="{% url 'fossil:repo_settings' slug=project.slug %}"
40
+ class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'settings' %}bg-gray-800 text-gray-100 border-b-2 border-brand{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}">
41
+ Settings
3842
</a>
3943
{% endif %}
4044
</nav>
4145
4246
ADDED templates/fossil/repo_settings.html
--- templates/fossil/_project_nav.html
+++ templates/fossil/_project_nav.html
@@ -33,8 +33,12 @@
33 </a>
34 {% if perms.projects.change_project %}
35 <a href="{% url 'fossil:sync' slug=project.slug %}"
36 class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'sync' %}bg-gray-800 text-gray-100 border-b-2 border-brand{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}">
37 {% if fossil_repo.remote_url %}Sync{% else %}Setup Sync{% endif %}
 
 
 
 
38 </a>
39 {% endif %}
40 </nav>
41
42 DDED templates/fossil/repo_settings.html
--- templates/fossil/_project_nav.html
+++ templates/fossil/_project_nav.html
@@ -33,8 +33,12 @@
33 </a>
34 {% if perms.projects.change_project %}
35 <a href="{% url 'fossil:sync' slug=project.slug %}"
36 class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'sync' %}bg-gray-800 text-gray-100 border-b-2 border-brand{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}">
37 {% if fossil_repo.remote_url %}Sync{% else %}Setup Sync{% endif %}
38 </a>
39 <a href="{% url 'fossil:repo_settings' slug=project.slug %}"
40 class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'settings' %}bg-gray-800 text-gray-100 border-b-2 border-brand{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}">
41 Settings
42 </a>
43 {% endif %}
44 </nav>
45
46 DDED templates/fossil/repo_settings.html
--- a/templates/fossil/repo_settings.html
+++ b/templates/fossil/repo_settings.html
@@ -0,0 +1,5 @@
1
+{% extends "base.html" %}
2
+{% block title %}Settings — {{ project.name }} — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<h1 class="text-2xl fo
--- a/templates/fossil/repo_settings.html
+++ b/templates/fossil/repo_settings.html
@@ -0,0 +1,5 @@
 
 
 
 
 
--- a/templates/fossil/repo_settings.html
+++ b/templates/fossil/repo_settings.html
@@ -0,0 +1,5 @@
1 {% extends "base.html" %}
2 {% block title %}Settings — {{ project.name }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <h1 class="text-2xl fo
--- templates/projects/project_form.html
+++ templates/projects/project_form.html
@@ -11,20 +11,60 @@
1111
1212
<form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
1313
{% csrf_token %}
1414
1515
{% for field in form %}
16
+ {% if field.name == "repo_source" or field.name == "clone_url" %}
17
+ {# Rendered manually below #}
18
+ {% else %}
1619
<div>
1720
<label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300">
1821
{{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %}
1922
</label>
2023
<div class="mt-1">{{ field }}</div>
2124
{% if field.errors %}
2225
<p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p>
2326
{% endif %}
2427
</div>
28
+ {% endif %}
2529
{% endfor %}
30
+
31
+ {% if not project %}
32
+ <!-- Repo Source (create only) -->
33
+ <div class="mt-6 pt-6 border-t border-gray-700" x-data="{ source: '{{ form.repo_source.value|default:"empty" }}' }">
34
+ <h3 class="text-sm font-medium text-gray-300 mb-3">Repository Source</h3>
35
+ <div class="space-y-3">
36
+ <label class="flex items-center gap-3 cursor-pointer">
37
+ <input type="radio" name="repo_source" value="empty" x-model="source"
38
+ class="text-brand focus:ring-brand">
39
+ <div>
40
+ <span class="text-sm text-gray-200">Create empty repository</span>
41
+ <p class="text-xs text-gray-500">Start fresh with a new .fossil file</p>
42
+ </div>
43
+ </label>
44
+ <label class="flex items-center gap-3 cursor-pointer">
45
+ <input type="radio" name="repo_source" value="fossil_url" x-model="source"
46
+ class="text-brand focus:ring-brand">
47
+ <div>
48
+ <span class="text-sm text-gray-200">Clone from Fossil URL</span>
49
+ <p class="text-xs text-gray-500">Clone an existing Fossil repository</p>
50
+ </div>
51
+ </label>
52
+ </div>
53
+
54
+ <div x-show="source === 'fossil_url'" x-transition class="mt-4">
55
+ <label class="block text-sm font-medium text-gray-300 mb-1">Clone URL</label>
56
+ <input type="url" name="clone_url" placeholder="https://fossil-scm.org/home"
57
+ value="{{ form.clone_url.value|default:'' }}"
58
+ class="w-full rounded-md border-gray-600 bg-gray-900 text-gray-100 text-sm px-3 py-2 focus:border-brand focus:ring-brand">
59
+ <p class="mt-1 text-xs text-gray-500">The Fossil repository URL (same as what you'd pass to <code>fossil clone</code>)</p>
60
+ {% if form.clone_url.errors %}
61
+ <p class="mt-1 text-sm text-red-400">{{ form.clone_url.errors.0 }}</p>
62
+ {% endif %}
63
+ </div>
64
+ </div>
65
+ {% endif %}
2666
2767
<div class="flex justify-end gap-3 pt-4">
2868
<a href="{% url 'projects:list' %}"
2969
class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
3070
Cancel
3171
3272
ADDED tests/test_repo_lifecycle.py
--- templates/projects/project_form.html
+++ templates/projects/project_form.html
@@ -11,20 +11,60 @@
11
12 <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
13 {% csrf_token %}
14
15 {% for field in form %}
 
 
 
16 <div>
17 <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300">
18 {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %}
19 </label>
20 <div class="mt-1">{{ field }}</div>
21 {% if field.errors %}
22 <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p>
23 {% endif %}
24 </div>
 
25 {% endfor %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
27 <div class="flex justify-end gap-3 pt-4">
28 <a href="{% url 'projects:list' %}"
29 class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
30 Cancel
31
32 DDED tests/test_repo_lifecycle.py
--- templates/projects/project_form.html
+++ templates/projects/project_form.html
@@ -11,20 +11,60 @@
11
12 <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
13 {% csrf_token %}
14
15 {% for field in form %}
16 {% if field.name == "repo_source" or field.name == "clone_url" %}
17 {# Rendered manually below #}
18 {% else %}
19 <div>
20 <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300">
21 {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %}
22 </label>
23 <div class="mt-1">{{ field }}</div>
24 {% if field.errors %}
25 <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p>
26 {% endif %}
27 </div>
28 {% endif %}
29 {% endfor %}
30
31 {% if not project %}
32 <!-- Repo Source (create only) -->
33 <div class="mt-6 pt-6 border-t border-gray-700" x-data="{ source: '{{ form.repo_source.value|default:"empty" }}' }">
34 <h3 class="text-sm font-medium text-gray-300 mb-3">Repository Source</h3>
35 <div class="space-y-3">
36 <label class="flex items-center gap-3 cursor-pointer">
37 <input type="radio" name="repo_source" value="empty" x-model="source"
38 class="text-brand focus:ring-brand">
39 <div>
40 <span class="text-sm text-gray-200">Create empty repository</span>
41 <p class="text-xs text-gray-500">Start fresh with a new .fossil file</p>
42 </div>
43 </label>
44 <label class="flex items-center gap-3 cursor-pointer">
45 <input type="radio" name="repo_source" value="fossil_url" x-model="source"
46 class="text-brand focus:ring-brand">
47 <div>
48 <span class="text-sm text-gray-200">Clone from Fossil URL</span>
49 <p class="text-xs text-gray-500">Clone an existing Fossil repository</p>
50 </div>
51 </label>
52 </div>
53
54 <div x-show="source === 'fossil_url'" x-transition class="mt-4">
55 <label class="block text-sm font-medium text-gray-300 mb-1">Clone URL</label>
56 <input type="url" name="clone_url" placeholder="https://fossil-scm.org/home"
57 value="{{ form.clone_url.value|default:'' }}"
58 class="w-full rounded-md border-gray-600 bg-gray-900 text-gray-100 text-sm px-3 py-2 focus:border-brand focus:ring-brand">
59 <p class="mt-1 text-xs text-gray-500">The Fossil repository URL (same as what you'd pass to <code>fossil clone</code>)</p>
60 {% if form.clone_url.errors %}
61 <p class="mt-1 text-sm text-red-400">{{ form.clone_url.errors.0 }}</p>
62 {% endif %}
63 </div>
64 </div>
65 {% endif %}
66
67 <div class="flex justify-end gap-3 pt-4">
68 <a href="{% url 'projects:list' %}"
69 class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
70 Cancel
71
72 DDED tests/test_repo_lifecycle.py
--- a/tests/test_repo_lifecycle.py
+++ b/tests/test_repo_lifecycle.py
@@ -0,0 +1,301 @@
1
+"""Tests for the repository lifecycle UI: project creation with repo source, and repo settings."""
2
+
3
+from unittest.mock import MagicMock, patch
4
+
5
+import pytest
6
+from django.contrib.auth.models import User
7
+from django.test import Client
8
+
9
+from fossil.models import FossilRepository
10
+from organization.models import Team
11
+from projects.models import Project, ProjectTeam
12
+
13
+
14
+@pytest.fixture
15
+def fossil_repo_obj(sample_project):
16
+ """Return the auto-created FossilRepository for sample_project."""
17
+ return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True)
18
+
19
+
20
+@pytest.fixture
21
+def writer_user(db, admin_user, sample_project):
22
+ """User with write access but not admin."""
23
+ writer = User.objects.create_user(username="writer", password="testpass123")
24
+ team = Team.objects.create(name="Writers", organization=sample_project.organization, created_by=admin_user)
25
+ team.members.add(writer)
26
+ ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=admin_user)
27
+ return writer
28
+
29
+
30
+@pytest.fixture
31
+def writer_client(writer_user):
32
+ client = Client()
33
+ client.login(username="writer", password="testpass123")
34
+ return client
35
+
36
+
37
+@pytest.fixture
38
+def admin_team_user(db, admin_user, sample_project):
39
+ """User with admin team role on the sample project."""
40
+ admin_team_member = User.objects.create_user(username="projadmin", password="testpass123")
41
+ team = Team.objects.create(name="Admins", organization=sample_project.organization, created_by=admin_user)
42
+ team.members.add(admin_team_member)
43
+ ProjectTeam.objects.create(project=sample_project, team=team, role="admin", created_by=admin_user)
44
+ return admin_team_member
45
+
46
+
47
+@pytest.fixture
48
+def admin_team_client(admin_team_user):
49
+ client = Client()
50
+ client.login(username="projadmin", password="testpass123")
51
+ return client
52
+
53
+
54
+# --- Project Create Form Tests ---
55
+
56
+
57
+@pytest.mark.django_db
58
+class TestProjectCreateForm:
59
+ def test_create_form_shows_repo_source(self, admin_client):
60
+ response = admin_client.get("/projects/create/")
61
+ assert response.status_code == 200
62
+ content = response.content.decode()
63
+ assert "repo_source" in content
64
+ assert "Create empty repository" in content
65
+ assert "Clone from Fossil URL" in content
66
+
67
+ def test_create_empty_repo(self, admin_client, org):
68
+ response = admin_client.post(
69
+ "/projects/create/",
70
+ {"name": "Empty Repo", "visibility": "private", "repo_source": "empty"},
71
+ )
72
+ assert response.status_code == 302
73
+ project = Project.objects.get(name="Empty Repo")
74
+ assert project is not None
75
+ fossil_repo = FossilRepository.objects.get(project=project)
76
+ assert fossil_repo.filename == f"{project.slug}.fossil"
77
+ assert fossil_repo.remote_url == ""
78
+
79
+ def test_create_with_missing_clone_url_fails(self, admin_client, org):
80
+ response = admin_client.post(
81
+ "/projects/create/",
82
+ {"name": "Clone Fail", "visibility": "private", "repo_source": "fossil_url", "clone_url": ""},
83
+ )
84
+ # Form should re-render with errors, not redirect
85
+ assert response.status_code == 200
86
+ content = response.content.decode()
87
+ assert "Clone URL is required" in content
88
+
89
+ @patch("projects.views._clone_fossil_repo")
90
+ def test_create_clone_calls_helper(self, mock_clone, admin_client, org):
91
+ response = admin_client.post(
92
+ "/projects/create/",
93
+ {
94
+ "name": "Cloned Repo",
95
+ "visibility": "private",
96
+ "repo_source": "fossil_url",
97
+ "clone_url": "https://fossil-scm.org/home",
98
+ },
99
+ )
100
+ assert response.status_code == 302
101
+ project = Project.objects.get(name="Cloned Repo")
102
+ mock_clone.assert_called_once()
103
+ call_args = mock_clone.call_args
104
+ assert call_args[0][1] == project
105
+ assert call_args[0][2] == "https://fossil-scm.org/home"
106
+
107
+ def test_create_without_repo_source_defaults_to_empty(self, admin_client, org):
108
+ response = admin_client.post(
109
+ "/projects/create/",
110
+ {"name": "Default Source", "visibility": "private"},
111
+ )
112
+ assert response.status_code == 302
113
+ project = Project.objects.get(name="Default Source")
114
+ fossil_repo = FossilRepository.objects.get(project=project)
115
+ assert fossil_repo.remote_url == ""
116
+
117
+ def test_edit_form_does_not_show_repo_source(self, admin_client, sample_project):
118
+ response = admin_client.get(f"/projects/{sample_project.slug}/edit/")
119
+ assert response.status_code == 200
120
+ content = response.content.decode()
121
+ assert "Repository Source" not in content
122
+
123
+
124
+# --- Project Update Form Tests (no repo source fields) ---
125
+
126
+
127
+@pytest.mark.django_db
128
+class TestProjectUpdateExcludesRepoSource:
129
+ def test_update_preserves_project(self, admin_client, sample_project):
130
+ response = admin_client.post(
131
+ f"/projects/{sample_project.slug}/edit/",
132
+ {"name": "Updated Name", "visibility": "public"},
133
+ )
134
+ assert response.status_code == 302
135
+ sample_project.refresh_from_db()
136
+ assert sample_project.name == "Updated Name"
137
+
138
+
139
+# --- Repo Settings View Tests ---
140
+
141
+
142
+@pytest.mark.django_db
143
+class TestRepoSettingsAccess:
144
+ def test_settings_denied_for_anon(self, client, sample_project, fossil_repo_obj):
145
+ response = client.get(f"/projects/{sample_project.slug}/fossil/settings/")
146
+ # Redirects to login for anon
147
+ assert response.status_code == 302
148
+
149
+ def test_settings_denied_for_writer(self, writer_client, sample_project, fossil_repo_obj):
150
+ response = writer_client.get(f"/projects/{sample_project.slug}/fossil/settings/")
151
+ assert response.status_code == 403
152
+
153
+ def test_settings_allowed_for_superuser(self, admin_client, sample_project, fossil_repo_obj):
154
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/settings/")
155
+ assert response.status_code == 200
156
+
157
+ def test_settings_allowed_for_project_admin(self, admin_team_client, sample_project, fossil_repo_obj):
158
+ response = admin_team_client.get(f"/projects/{sample_project.slug}/fossil/settings/")
159
+ assert response.status_code == 200
160
+
161
+
162
+@pytest.mark.django_db
163
+class TestRepoSettingsContent:
164
+ def test_settings_page_shows_filename(self, admin_client, sample_project, fossil_repo_obj):
165
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/settings/")
166
+ content = response.content.decode()
167
+ assert fossil_repo_obj.filename in content
168
+
169
+ def test_settings_page_shows_remote_form(self, admin_client, sample_project, fossil_repo_obj):
170
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/settings/")
171
+ content = response.content.decode()
172
+ assert 'name="remote_url"' in content
173
+ assert "Save Remote" in content
174
+
175
+ def test_settings_page_shows_clone_urls(self, admin_client, sample_project, fossil_repo_obj):
176
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/settings/")
177
+ content = response.content.decode()
178
+ assert "Clone URLs" in content
179
+
180
+ def test_settings_page_shows_danger_zone(self, admin_client, sample_project, fossil_repo_obj):
181
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/settings/")
182
+ content = response.content.decode()
183
+ assert "Danger Zone" in content
184
+
185
+ def test_settings_active_tab(self, admin_client, sample_project, fossil_repo_obj):
186
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/settings/")
187
+ content = response.content.decode()
188
+ # The Settings tab should be active (has the active CSS classes)
189
+ assert "Settings" in content
190
+
191
+
192
+@pytest.mark.django_db
193
+class TestRepoSettingsActions:
194
+ def test_update_remote_url(self, admin_client, sample_project, fossil_repo_obj):
195
+ response = admin_client.post(
196
+ f"/projects/{sample_project.slug}/fossil/settings/",
197
+ {"action": "update_remote", "remote_url": "https://fossil-scm.org/home"},
198
+ )
199
+ assert response.status_code == 302
200
+ fossil_repo_obj.refresh_from_db()
201
+ assert fossil_repo_obj.remote_url == "https://fossil-scm.org/home"
202
+
203
+ def test_clear_remote_url(self, admin_client, sample_project, fossil_repo_obj):
204
+ fossil_repo_obj.remote_url = "https://old-url.example.com"
205
+ fossil_repo_obj.save()
206
+ response = admin_client.post(
207
+ f"/projects/{sample_project.slug}/fossil/settings/",
208
+ {"action": "update_remote", "remote_url": ""},
209
+ )
210
+ assert response.status_code == 302
211
+ fossil_repo_obj.refresh_from_db()
212
+ assert fossil_repo_obj.remote_url == ""
213
+
214
+ def test_update_remote_denied_for_writer(self, writer_client, sample_project, fossil_repo_obj):
215
+ response = writer_client.post(
216
+ f"/projects/{sample_project.slug}/fossil/settings/",
217
+ {"action": "update_remote", "remote_url": "https://evil.example.com"},
218
+ )
219
+ assert response.status_code == 403
220
+ fossil_repo_obj.refresh_from_db()
221
+ assert fossil_repo_obj.remote_url != "https://evil.example.com"
222
+
223
+
224
+# --- Nav Tab Tests ---
225
+
226
+
227
+@pytest.mark.django_db
228
+class TestProjectNavSettings:
229
+ def test_settings_tab_visible_for_admin(self, admin_client, sample_project, fossil_repo_obj):
230
+ """The Settings tab should appear in the nav for admins on fossil views."""
231
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/settings/")
232
+ content = response.content.decode()
233
+ assert "Settings" in content
234
+ assert f"/projects/{sample_project.slug}/fossil/settings/" in content
235
+
236
+
237
+# --- Signal Guard Tests ---
238
+
239
+
240
+@pytest.mark.django_db
241
+class TestSignalExistingFileGuard:
242
+ @patch("fossil.cli.FossilCLI")
243
+ def test_signal_skips_init_when_file_exists(self, mock_cli_cls, org, admin_user, tmp_path):
244
+ """When a .fossil file already exists, the signal should skip fossil init."""
245
+ mock_cli = MagicMock()
246
+ mock_cli.is_available.return_value = True
247
+ mock_cli_cls.return_value = mock_cli
248
+
249
+ # Create the project -- the signal fires
250
+ project = Project.objects.create(name="Pre-existing", organization=org, created_by=admin_user)
251
+
252
+ # The signal creates a FossilRepository record. Since the .fossil file won't exist
253
+ # on disk in tests (no real FOSSIL_DATA_DIR), the signal will attempt init via CLI.
254
+ # The key assertion is that the record was created and the code path doesn't crash.
255
+ assert FossilRepository.objects.filter(project=project).exists()
256
+
257
+ def test_signal_creates_repo_record(self, org, admin_user):
258
+ """The signal creates a FossilRepository record when a Project is created."""
259
+ project = Project.objects.create(name="Signal Test", organization=org, created_by=admin_user)
260
+ assert FossilRepository.objects.filter(project=project).exists()
261
+ fossil_repo = FossilRepository.objects.get(project=project)
262
+ assert fossil_repo.filename == f"{project.slug}.fossil"
263
+
264
+
265
+# --- Form Validation Tests ---
266
+
267
+
268
+@pytest.mark.django_db
269
+class TestProjectFormValidation:
270
+ def test_form_valid_with_empty_source(self):
271
+ from projects.forms import ProjectForm
272
+
273
+ form = ProjectForm(data={"name": "Test", "visibility": "private", "repo_source": "empty"})
274
+ assert form.is_valid()
275
+
276
+ def test_form_valid_with_clone_url(self):
277
+ from projects.forms import ProjectForm
278
+
279
+ form = ProjectForm(
280
+ data={
281
+ "name": "Test Clone",
282
+ "visibility": "private",
283
+ "repo_source": "fossil_url",
284
+ "clone_url": "https://fossil-scm.org/home",
285
+ }
286
+ )
287
+ assert form.is_valid()
288
+
289
+ def test_form_invalid_clone_without_url(self):
290
+ from projects.forms import ProjectForm
291
+
292
+ form = ProjectForm(
293
+ data={
294
+ "name": "No URL",
295
+ "visibility": "private",
296
+ "repo_source": "fossil_url",
297
+ "clone_url": "",
298
+ }
299
+ )
300
+ assert not form.is_valid()
301
+ assert "clone_url" in form.errors
--- a/tests/test_repo_lifecycle.py
+++ b/tests/test_repo_lifecycle.py
@@ -0,0 +1,301 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/test_repo_lifecycle.py
+++ b/tests/test_repo_lifecycle.py
@@ -0,0 +1,301 @@
1 """Tests for the repository lifecycle UI: project creation with repo source, and repo settings."""
2
3 from unittest.mock import MagicMock, patch
4
5 import pytest
6 from django.contrib.auth.models import User
7 from django.test import Client
8
9 from fossil.models import FossilRepository
10 from organization.models import Team
11 from projects.models import Project, ProjectTeam
12
13
14 @pytest.fixture
15 def fossil_repo_obj(sample_project):
16 """Return the auto-created FossilRepository for sample_project."""
17 return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True)
18
19
20 @pytest.fixture
21 def writer_user(db, admin_user, sample_project):
22 """User with write access but not admin."""
23 writer = User.objects.create_user(username="writer", password="testpass123")
24 team = Team.objects.create(name="Writers", organization=sample_project.organization, created_by=admin_user)
25 team.members.add(writer)
26 ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=admin_user)
27 return writer
28
29
30 @pytest.fixture
31 def writer_client(writer_user):
32 client = Client()
33 client.login(username="writer", password="testpass123")
34 return client
35
36
37 @pytest.fixture
38 def admin_team_user(db, admin_user, sample_project):
39 """User with admin team role on the sample project."""
40 admin_team_member = User.objects.create_user(username="projadmin", password="testpass123")
41 team = Team.objects.create(name="Admins", organization=sample_project.organization, created_by=admin_user)
42 team.members.add(admin_team_member)
43 ProjectTeam.objects.create(project=sample_project, team=team, role="admin", created_by=admin_user)
44 return admin_team_member
45
46
47 @pytest.fixture
48 def admin_team_client(admin_team_user):
49 client = Client()
50 client.login(username="projadmin", password="testpass123")
51 return client
52
53
54 # --- Project Create Form Tests ---
55
56
57 @pytest.mark.django_db
58 class TestProjectCreateForm:
59 def test_create_form_shows_repo_source(self, admin_client):
60 response = admin_client.get("/projects/create/")
61 assert response.status_code == 200
62 content = response.content.decode()
63 assert "repo_source" in content
64 assert "Create empty repository" in content
65 assert "Clone from Fossil URL" in content
66
67 def test_create_empty_repo(self, admin_client, org):
68 response = admin_client.post(
69 "/projects/create/",
70 {"name": "Empty Repo", "visibility": "private", "repo_source": "empty"},
71 )
72 assert response.status_code == 302
73 project = Project.objects.get(name="Empty Repo")
74 assert project is not None
75 fossil_repo = FossilRepository.objects.get(project=project)
76 assert fossil_repo.filename == f"{project.slug}.fossil"
77 assert fossil_repo.remote_url == ""
78
79 def test_create_with_missing_clone_url_fails(self, admin_client, org):
80 response = admin_client.post(
81 "/projects/create/",
82 {"name": "Clone Fail", "visibility": "private", "repo_source": "fossil_url", "clone_url": ""},
83 )
84 # Form should re-render with errors, not redirect
85 assert response.status_code == 200
86 content = response.content.decode()
87 assert "Clone URL is required" in content
88
89 @patch("projects.views._clone_fossil_repo")
90 def test_create_clone_calls_helper(self, mock_clone, admin_client, org):
91 response = admin_client.post(
92 "/projects/create/",
93 {
94 "name": "Cloned Repo",
95 "visibility": "private",
96 "repo_source": "fossil_url",
97 "clone_url": "https://fossil-scm.org/home",
98 },
99 )
100 assert response.status_code == 302
101 project = Project.objects.get(name="Cloned Repo")
102 mock_clone.assert_called_once()
103 call_args = mock_clone.call_args
104 assert call_args[0][1] == project
105 assert call_args[0][2] == "https://fossil-scm.org/home"
106
107 def test_create_without_repo_source_defaults_to_empty(self, admin_client, org):
108 response = admin_client.post(
109 "/projects/create/",
110 {"name": "Default Source", "visibility": "private"},
111 )
112 assert response.status_code == 302
113 project = Project.objects.get(name="Default Source")
114 fossil_repo = FossilRepository.objects.get(project=project)
115 assert fossil_repo.remote_url == ""
116
117 def test_edit_form_does_not_show_repo_source(self, admin_client, sample_project):
118 response = admin_client.get(f"/projects/{sample_project.slug}/edit/")
119 assert response.status_code == 200
120 content = response.content.decode()
121 assert "Repository Source" not in content
122
123
124 # --- Project Update Form Tests (no repo source fields) ---
125
126
127 @pytest.mark.django_db
128 class TestProjectUpdateExcludesRepoSource:
129 def test_update_preserves_project(self, admin_client, sample_project):
130 response = admin_client.post(
131 f"/projects/{sample_project.slug}/edit/",
132 {"name": "Updated Name", "visibility": "public"},
133 )
134 assert response.status_code == 302
135 sample_project.refresh_from_db()
136 assert sample_project.name == "Updated Name"
137
138
139 # --- Repo Settings View Tests ---
140
141
142 @pytest.mark.django_db
143 class TestRepoSettingsAccess:
144 def test_settings_denied_for_anon(self, client, sample_project, fossil_repo_obj):
145 response = client.get(f"/projects/{sample_project.slug}/fossil/settings/")
146 # Redirects to login for anon
147 assert response.status_code == 302
148
149 def test_settings_denied_for_writer(self, writer_client, sample_project, fossil_repo_obj):
150 response = writer_client.get(f"/projects/{sample_project.slug}/fossil/settings/")
151 assert response.status_code == 403
152
153 def test_settings_allowed_for_superuser(self, admin_client, sample_project, fossil_repo_obj):
154 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/settings/")
155 assert response.status_code == 200
156
157 def test_settings_allowed_for_project_admin(self, admin_team_client, sample_project, fossil_repo_obj):
158 response = admin_team_client.get(f"/projects/{sample_project.slug}/fossil/settings/")
159 assert response.status_code == 200
160
161
162 @pytest.mark.django_db
163 class TestRepoSettingsContent:
164 def test_settings_page_shows_filename(self, admin_client, sample_project, fossil_repo_obj):
165 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/settings/")
166 content = response.content.decode()
167 assert fossil_repo_obj.filename in content
168
169 def test_settings_page_shows_remote_form(self, admin_client, sample_project, fossil_repo_obj):
170 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/settings/")
171 content = response.content.decode()
172 assert 'name="remote_url"' in content
173 assert "Save Remote" in content
174
175 def test_settings_page_shows_clone_urls(self, admin_client, sample_project, fossil_repo_obj):
176 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/settings/")
177 content = response.content.decode()
178 assert "Clone URLs" in content
179
180 def test_settings_page_shows_danger_zone(self, admin_client, sample_project, fossil_repo_obj):
181 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/settings/")
182 content = response.content.decode()
183 assert "Danger Zone" in content
184
185 def test_settings_active_tab(self, admin_client, sample_project, fossil_repo_obj):
186 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/settings/")
187 content = response.content.decode()
188 # The Settings tab should be active (has the active CSS classes)
189 assert "Settings" in content
190
191
192 @pytest.mark.django_db
193 class TestRepoSettingsActions:
194 def test_update_remote_url(self, admin_client, sample_project, fossil_repo_obj):
195 response = admin_client.post(
196 f"/projects/{sample_project.slug}/fossil/settings/",
197 {"action": "update_remote", "remote_url": "https://fossil-scm.org/home"},
198 )
199 assert response.status_code == 302
200 fossil_repo_obj.refresh_from_db()
201 assert fossil_repo_obj.remote_url == "https://fossil-scm.org/home"
202
203 def test_clear_remote_url(self, admin_client, sample_project, fossil_repo_obj):
204 fossil_repo_obj.remote_url = "https://old-url.example.com"
205 fossil_repo_obj.save()
206 response = admin_client.post(
207 f"/projects/{sample_project.slug}/fossil/settings/",
208 {"action": "update_remote", "remote_url": ""},
209 )
210 assert response.status_code == 302
211 fossil_repo_obj.refresh_from_db()
212 assert fossil_repo_obj.remote_url == ""
213
214 def test_update_remote_denied_for_writer(self, writer_client, sample_project, fossil_repo_obj):
215 response = writer_client.post(
216 f"/projects/{sample_project.slug}/fossil/settings/",
217 {"action": "update_remote", "remote_url": "https://evil.example.com"},
218 )
219 assert response.status_code == 403
220 fossil_repo_obj.refresh_from_db()
221 assert fossil_repo_obj.remote_url != "https://evil.example.com"
222
223
224 # --- Nav Tab Tests ---
225
226
227 @pytest.mark.django_db
228 class TestProjectNavSettings:
229 def test_settings_tab_visible_for_admin(self, admin_client, sample_project, fossil_repo_obj):
230 """The Settings tab should appear in the nav for admins on fossil views."""
231 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/settings/")
232 content = response.content.decode()
233 assert "Settings" in content
234 assert f"/projects/{sample_project.slug}/fossil/settings/" in content
235
236
237 # --- Signal Guard Tests ---
238
239
240 @pytest.mark.django_db
241 class TestSignalExistingFileGuard:
242 @patch("fossil.cli.FossilCLI")
243 def test_signal_skips_init_when_file_exists(self, mock_cli_cls, org, admin_user, tmp_path):
244 """When a .fossil file already exists, the signal should skip fossil init."""
245 mock_cli = MagicMock()
246 mock_cli.is_available.return_value = True
247 mock_cli_cls.return_value = mock_cli
248
249 # Create the project -- the signal fires
250 project = Project.objects.create(name="Pre-existing", organization=org, created_by=admin_user)
251
252 # The signal creates a FossilRepository record. Since the .fossil file won't exist
253 # on disk in tests (no real FOSSIL_DATA_DIR), the signal will attempt init via CLI.
254 # The key assertion is that the record was created and the code path doesn't crash.
255 assert FossilRepository.objects.filter(project=project).exists()
256
257 def test_signal_creates_repo_record(self, org, admin_user):
258 """The signal creates a FossilRepository record when a Project is created."""
259 project = Project.objects.create(name="Signal Test", organization=org, created_by=admin_user)
260 assert FossilRepository.objects.filter(project=project).exists()
261 fossil_repo = FossilRepository.objects.get(project=project)
262 assert fossil_repo.filename == f"{project.slug}.fossil"
263
264
265 # --- Form Validation Tests ---
266
267
268 @pytest.mark.django_db
269 class TestProjectFormValidation:
270 def test_form_valid_with_empty_source(self):
271 from projects.forms import ProjectForm
272
273 form = ProjectForm(data={"name": "Test", "visibility": "private", "repo_source": "empty"})
274 assert form.is_valid()
275
276 def test_form_valid_with_clone_url(self):
277 from projects.forms import ProjectForm
278
279 form = ProjectForm(
280 data={
281 "name": "Test Clone",
282 "visibility": "private",
283 "repo_source": "fossil_url",
284 "clone_url": "https://fossil-scm.org/home",
285 }
286 )
287 assert form.is_valid()
288
289 def test_form_invalid_clone_without_url(self):
290 from projects.forms import ProjectForm
291
292 form = ProjectForm(
293 data={
294 "name": "No URL",
295 "visibility": "private",
296 "repo_source": "fossil_url",
297 "clone_url": "",
298 }
299 )
300 assert not form.is_valid()
301 assert "clone_url" in form.errors

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button