FossilRepo
Add upstream sync: pull from remote, detect updates, Celery task Sync feature: - New /fossil/sync/ page with remote URL display and pull button - One-click "Pull from Upstream" fetches new artifacts from remote - Shows sync result: artifacts received, success/failure, raw output - "Sync" tab in project navigation (change permission required) Model: - remote_url field on FossilRepository - last_sync_at timestamp - upstream_artifacts_available counter CLI: - pull() method: runs fossil pull -R, returns artifacts_received count - get_remote_url() method: reads configured remote from .fossil file Celery: - check_upstream_updates task: every 15 minutes, pulls all repos with remotes - Updates metadata after successful pull (checkin count, size, timestamps) Filed #12 for Git mirror sync (GitHub/GitLab OAuth) as future work.
803470c846217252b0c07c828241c0b65867b1d114f27bf136d3d0f1ea25d29f
| --- config/settings.py | ||
| +++ config/settings.py | ||
| @@ -192,10 +192,14 @@ | ||
| 192 | 192 | CELERY_BEAT_SCHEDULE = { |
| 193 | 193 | "fossil-sync-metadata": { |
| 194 | 194 | "task": "fossil.sync_metadata", |
| 195 | 195 | "schedule": 300.0, # every 5 minutes |
| 196 | 196 | }, |
| 197 | + "fossil-check-upstream": { | |
| 198 | + "task": "fossil.check_upstream", | |
| 199 | + "schedule": 900.0, # every 15 minutes | |
| 200 | + }, | |
| 197 | 201 | } |
| 198 | 202 | CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True |
| 199 | 203 | |
| 200 | 204 | # --- CORS --- |
| 201 | 205 | |
| 202 | 206 |
| --- config/settings.py | |
| +++ config/settings.py | |
| @@ -192,10 +192,14 @@ | |
| 192 | CELERY_BEAT_SCHEDULE = { |
| 193 | "fossil-sync-metadata": { |
| 194 | "task": "fossil.sync_metadata", |
| 195 | "schedule": 300.0, # every 5 minutes |
| 196 | }, |
| 197 | } |
| 198 | CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True |
| 199 | |
| 200 | # --- CORS --- |
| 201 | |
| 202 |
| --- config/settings.py | |
| +++ config/settings.py | |
| @@ -192,10 +192,14 @@ | |
| 192 | CELERY_BEAT_SCHEDULE = { |
| 193 | "fossil-sync-metadata": { |
| 194 | "task": "fossil.sync_metadata", |
| 195 | "schedule": 300.0, # every 5 minutes |
| 196 | }, |
| 197 | "fossil-check-upstream": { |
| 198 | "task": "fossil.check_upstream", |
| 199 | "schedule": 900.0, # every 15 minutes |
| 200 | }, |
| 201 | } |
| 202 | CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True |
| 203 | |
| 204 | # --- CORS --- |
| 205 | |
| 206 |
| --- fossil/cli.py | ||
| +++ fossil/cli.py | ||
| @@ -99,10 +99,42 @@ | ||
| 99 | 99 | finally: |
| 100 | 100 | import shutil |
| 101 | 101 | |
| 102 | 102 | shutil.rmtree(tmpdir, ignore_errors=True) |
| 103 | 103 | return lines |
| 104 | + | |
| 105 | + def pull(self, repo_path: Path) -> dict: | |
| 106 | + """Pull updates from the remote. Returns {success, artifacts_received, message}.""" | |
| 107 | + try: | |
| 108 | + result = subprocess.run( | |
| 109 | + [self.binary, "pull", "-R", str(repo_path)], | |
| 110 | + capture_output=True, | |
| 111 | + text=True, | |
| 112 | + timeout=60, | |
| 113 | + ) | |
| 114 | + import re | |
| 115 | + | |
| 116 | + artifacts = 0 | |
| 117 | + m = re.search(r"received:\s*(\d+)", result.stdout) | |
| 118 | + if m: | |
| 119 | + artifacts = int(m.group(1)) | |
| 120 | + return {"success": result.returncode == 0, "artifacts_received": artifacts, "message": result.stdout.strip()} | |
| 121 | + except (FileNotFoundError, subprocess.TimeoutExpired) as e: | |
| 122 | + return {"success": False, "artifacts_received": 0, "message": str(e)} | |
| 123 | + | |
| 124 | + def get_remote_url(self, repo_path: Path) -> str: | |
| 125 | + """Get the configured remote URL for a repo.""" | |
| 126 | + try: | |
| 127 | + result = subprocess.run( | |
| 128 | + [self.binary, "remote", "-R", str(repo_path)], | |
| 129 | + capture_output=True, | |
| 130 | + text=True, | |
| 131 | + timeout=10, | |
| 132 | + ) | |
| 133 | + return result.stdout.strip() if result.returncode == 0 else "" | |
| 134 | + except (FileNotFoundError, subprocess.TimeoutExpired): | |
| 135 | + return "" | |
| 104 | 136 | |
| 105 | 137 | def wiki_commit(self, repo_path: Path, page_name: str, content: str, user: str = "") -> bool: |
| 106 | 138 | """Create or update a wiki page. Pipes content to fossil wiki commit.""" |
| 107 | 139 | cmd = [self.binary, "wiki", "commit", page_name, "-R", str(repo_path)] |
| 108 | 140 | if user: |
| 109 | 141 | |
| 110 | 142 | ADDED fossil/migrations/0002_fossilrepository_last_sync_at_and_more.py |
| --- fossil/cli.py | |
| +++ fossil/cli.py | |
| @@ -99,10 +99,42 @@ | |
| 99 | finally: |
| 100 | import shutil |
| 101 | |
| 102 | shutil.rmtree(tmpdir, ignore_errors=True) |
| 103 | return lines |
| 104 | |
| 105 | def wiki_commit(self, repo_path: Path, page_name: str, content: str, user: str = "") -> bool: |
| 106 | """Create or update a wiki page. Pipes content to fossil wiki commit.""" |
| 107 | cmd = [self.binary, "wiki", "commit", page_name, "-R", str(repo_path)] |
| 108 | if user: |
| 109 | |
| 110 | DDED fossil/migrations/0002_fossilrepository_last_sync_at_and_more.py |
| --- fossil/cli.py | |
| +++ fossil/cli.py | |
| @@ -99,10 +99,42 @@ | |
| 99 | finally: |
| 100 | import shutil |
| 101 | |
| 102 | shutil.rmtree(tmpdir, ignore_errors=True) |
| 103 | return lines |
| 104 | |
| 105 | def pull(self, repo_path: Path) -> dict: |
| 106 | """Pull updates from the remote. Returns {success, artifacts_received, message}.""" |
| 107 | try: |
| 108 | result = subprocess.run( |
| 109 | [self.binary, "pull", "-R", str(repo_path)], |
| 110 | capture_output=True, |
| 111 | text=True, |
| 112 | timeout=60, |
| 113 | ) |
| 114 | import re |
| 115 | |
| 116 | artifacts = 0 |
| 117 | m = re.search(r"received:\s*(\d+)", result.stdout) |
| 118 | if m: |
| 119 | artifacts = int(m.group(1)) |
| 120 | return {"success": result.returncode == 0, "artifacts_received": artifacts, "message": result.stdout.strip()} |
| 121 | except (FileNotFoundError, subprocess.TimeoutExpired) as e: |
| 122 | return {"success": False, "artifacts_received": 0, "message": str(e)} |
| 123 | |
| 124 | def get_remote_url(self, repo_path: Path) -> str: |
| 125 | """Get the configured remote URL for a repo.""" |
| 126 | try: |
| 127 | result = subprocess.run( |
| 128 | [self.binary, "remote", "-R", str(repo_path)], |
| 129 | capture_output=True, |
| 130 | text=True, |
| 131 | timeout=10, |
| 132 | ) |
| 133 | return result.stdout.strip() if result.returncode == 0 else "" |
| 134 | except (FileNotFoundError, subprocess.TimeoutExpired): |
| 135 | return "" |
| 136 | |
| 137 | def wiki_commit(self, repo_path: Path, page_name: str, content: str, user: str = "") -> bool: |
| 138 | """Create or update a wiki page. Pipes content to fossil wiki commit.""" |
| 139 | cmd = [self.binary, "wiki", "commit", page_name, "-R", str(repo_path)] |
| 140 | if user: |
| 141 | |
| 142 | DDED fossil/migrations/0002_fossilrepository_last_sync_at_and_more.py |
| --- a/fossil/migrations/0002_fossilrepository_last_sync_at_and_more.py | ||
| +++ b/fossil/migrations/0002_fossilrepository_last_sync_at_and_more.py | ||
| @@ -0,0 +1,42 @@ | ||
| 1 | +# Generated by Django 5.2.12 on 2026-04-07 00:49 | |
| 2 | + | |
| 3 | +from django.db import migrations, models | |
| 4 | + | |
| 5 | + | |
| 6 | +class Migration(migrations.Migration): | |
| 7 | + dependencies = [ | |
| 8 | + ("fossil", "0001_initial"), | |
| 9 | + ] | |
| 10 | + | |
| 11 | + operations = [ | |
| 12 | + migrations.AddField( | |
| 13 | + model_name="fossilrepository", | |
| 14 | + name="last_sync_at", | |
| 15 | + field=models.DateTimeField(blank=True, null=True), | |
| 16 | + ), | |
| 17 | + migrations.AddField( | |
| 18 | + model_name="fossilrepository", | |
| 19 | + name="remote_url", | |
| 20 | + field=models.URLField(blank=True, default="", help_text="Upstream remote URL for sync"), | |
| 21 | + ), | |
| 22 | + migrations.AddField( | |
| 23 | + model_name="fossilrepository", | |
| 24 | + name="upstream_artifacts_available", | |
| 25 | + field=models.PositiveIntegerField(default=0, help_text="New artifacts available from upstream"), | |
| 26 | + ), | |
| 27 | + migrations.AddField( | |
| 28 | + model_name="historicalfossilrepository", | |
| 29 | + name="last_sync_at", | |
| 30 | + field=models.DateTimeField(blank=True, null=True), | |
| 31 | + ), | |
| 32 | + migrations.AddField( | |
| 33 | + model_name="historicalfossilrepository", | |
| 34 | + name="remote_url", | |
| 35 | + field=models.URLField(blank=True, default="", help_text="Upstream remote URL for sync"), | |
| 36 | + ), | |
| 37 | + migrations.AddField( | |
| 38 | + model_name="historicalfossilrepository", | |
| 39 | + name="upstream_artifacts_available", | |
| 40 | + field=models.PositiveIntegerField(default=0, help_text="New artifacts available from upstream"), | |
| 41 | + ), | |
| 42 | + ] |
| --- a/fossil/migrations/0002_fossilrepository_last_sync_at_and_more.py | |
| +++ b/fossil/migrations/0002_fossilrepository_last_sync_at_and_more.py | |
| @@ -0,0 +1,42 @@ | |
| --- a/fossil/migrations/0002_fossilrepository_last_sync_at_and_more.py | |
| +++ b/fossil/migrations/0002_fossilrepository_last_sync_at_and_more.py | |
| @@ -0,0 +1,42 @@ | |
| 1 | # Generated by Django 5.2.12 on 2026-04-07 00:49 |
| 2 | |
| 3 | from django.db import migrations, models |
| 4 | |
| 5 | |
| 6 | class Migration(migrations.Migration): |
| 7 | dependencies = [ |
| 8 | ("fossil", "0001_initial"), |
| 9 | ] |
| 10 | |
| 11 | operations = [ |
| 12 | migrations.AddField( |
| 13 | model_name="fossilrepository", |
| 14 | name="last_sync_at", |
| 15 | field=models.DateTimeField(blank=True, null=True), |
| 16 | ), |
| 17 | migrations.AddField( |
| 18 | model_name="fossilrepository", |
| 19 | name="remote_url", |
| 20 | field=models.URLField(blank=True, default="", help_text="Upstream remote URL for sync"), |
| 21 | ), |
| 22 | migrations.AddField( |
| 23 | model_name="fossilrepository", |
| 24 | name="upstream_artifacts_available", |
| 25 | field=models.PositiveIntegerField(default=0, help_text="New artifacts available from upstream"), |
| 26 | ), |
| 27 | migrations.AddField( |
| 28 | model_name="historicalfossilrepository", |
| 29 | name="last_sync_at", |
| 30 | field=models.DateTimeField(blank=True, null=True), |
| 31 | ), |
| 32 | migrations.AddField( |
| 33 | model_name="historicalfossilrepository", |
| 34 | name="remote_url", |
| 35 | field=models.URLField(blank=True, default="", help_text="Upstream remote URL for sync"), |
| 36 | ), |
| 37 | migrations.AddField( |
| 38 | model_name="historicalfossilrepository", |
| 39 | name="upstream_artifacts_available", |
| 40 | field=models.PositiveIntegerField(default=0, help_text="New artifacts available from upstream"), |
| 41 | ), |
| 42 | ] |
| --- fossil/models.py | ||
| +++ fossil/models.py | ||
| @@ -13,10 +13,15 @@ | ||
| 13 | 13 | file_size_bytes = models.BigIntegerField(default=0) |
| 14 | 14 | fossil_project_code = models.CharField(max_length=40, blank=True, default="") |
| 15 | 15 | last_checkin_at = models.DateTimeField(null=True, blank=True) |
| 16 | 16 | checkin_count = models.PositiveIntegerField(default=0) |
| 17 | 17 | |
| 18 | + # Remote sync | |
| 19 | + remote_url = models.URLField(blank=True, default="", help_text="Upstream remote URL for sync") | |
| 20 | + last_sync_at = models.DateTimeField(null=True, blank=True) | |
| 21 | + upstream_artifacts_available = models.PositiveIntegerField(default=0, help_text="New artifacts available from upstream") | |
| 22 | + | |
| 18 | 23 | # S3 tracking |
| 19 | 24 | s3_key = models.CharField(max_length=500, blank=True, default="") |
| 20 | 25 | s3_last_replicated_at = models.DateTimeField(null=True, blank=True) |
| 21 | 26 | |
| 22 | 27 | objects = ActiveManager() |
| 23 | 28 |
| --- fossil/models.py | |
| +++ fossil/models.py | |
| @@ -13,10 +13,15 @@ | |
| 13 | file_size_bytes = models.BigIntegerField(default=0) |
| 14 | fossil_project_code = models.CharField(max_length=40, blank=True, default="") |
| 15 | last_checkin_at = models.DateTimeField(null=True, blank=True) |
| 16 | checkin_count = models.PositiveIntegerField(default=0) |
| 17 | |
| 18 | # S3 tracking |
| 19 | s3_key = models.CharField(max_length=500, blank=True, default="") |
| 20 | s3_last_replicated_at = models.DateTimeField(null=True, blank=True) |
| 21 | |
| 22 | objects = ActiveManager() |
| 23 |
| --- fossil/models.py | |
| +++ fossil/models.py | |
| @@ -13,10 +13,15 @@ | |
| 13 | file_size_bytes = models.BigIntegerField(default=0) |
| 14 | fossil_project_code = models.CharField(max_length=40, blank=True, default="") |
| 15 | last_checkin_at = models.DateTimeField(null=True, blank=True) |
| 16 | checkin_count = models.PositiveIntegerField(default=0) |
| 17 | |
| 18 | # Remote sync |
| 19 | remote_url = models.URLField(blank=True, default="", help_text="Upstream remote URL for sync") |
| 20 | last_sync_at = models.DateTimeField(null=True, blank=True) |
| 21 | upstream_artifacts_available = models.PositiveIntegerField(default=0, help_text="New artifacts available from upstream") |
| 22 | |
| 23 | # S3 tracking |
| 24 | s3_key = models.CharField(max_length=500, blank=True, default="") |
| 25 | s3_last_replicated_at = models.DateTimeField(null=True, blank=True) |
| 26 | |
| 27 | objects = ActiveManager() |
| 28 |
| --- fossil/tasks.py | ||
| +++ fossil/tasks.py | ||
| @@ -64,5 +64,54 @@ | ||
| 64 | 64 | note=note, |
| 65 | 65 | created_by=repo.created_by, |
| 66 | 66 | ) |
| 67 | 67 | snapshot.file.save(f"{repo.filename}_{sha[:8]}.fossil", ContentFile(data), save=True) |
| 68 | 68 | logger.info("Created snapshot for %s (hash: %s)", repo.filename, sha[:8]) |
| 69 | + | |
| 70 | + | |
| 71 | +@shared_task(name="fossil.check_upstream") | |
| 72 | +def check_upstream_updates(): | |
| 73 | + """Check all repos with remote URLs for available updates.""" | |
| 74 | + from fossil.cli import FossilCLI | |
| 75 | + from fossil.models import FossilRepository | |
| 76 | + | |
| 77 | + cli = FossilCLI() | |
| 78 | + if not cli.is_available(): | |
| 79 | + return | |
| 80 | + | |
| 81 | + from django.utils import timezone | |
| 82 | + | |
| 83 | + for repo in FossilRepository.objects.exclude(remote_url=""): | |
| 84 | + if not repo.exists_on_disk: | |
| 85 | + continue | |
| 86 | + try: | |
| 87 | + result = cli.pull(repo.full_path) | |
| 88 | + if result["success"] and result["artifacts_received"] > 0: | |
| 89 | + repo.upstream_artifacts_available = result["artifacts_received"] | |
| 90 | + repo.last_sync_at = timezone.now() | |
| 91 | + # Update metadata after pull | |
| 92 | + from fossil.reader import FossilReader | |
| 93 | + | |
| 94 | + with FossilReader(repo.full_path) as reader: | |
| 95 | + repo.checkin_count = reader.get_checkin_count() | |
| 96 | + timeline = reader.get_timeline(limit=1, event_type="ci") | |
| 97 | + if timeline: | |
| 98 | + repo.last_checkin_at = timeline[0].timestamp | |
| 99 | + repo.file_size_bytes = repo.full_path.stat().st_size | |
| 100 | + repo.save( | |
| 101 | + update_fields=[ | |
| 102 | + "upstream_artifacts_available", | |
| 103 | + "last_sync_at", | |
| 104 | + "checkin_count", | |
| 105 | + "last_checkin_at", | |
| 106 | + "file_size_bytes", | |
| 107 | + "updated_at", | |
| 108 | + "version", | |
| 109 | + ] | |
| 110 | + ) | |
| 111 | + logger.info("Pulled %d artifacts for %s (new count: %d)", result["artifacts_received"], repo.filename, repo.checkin_count) | |
| 112 | + else: | |
| 113 | + repo.upstream_artifacts_available = 0 | |
| 114 | + repo.last_sync_at = timezone.now() | |
| 115 | + repo.save(update_fields=["upstream_artifacts_available", "last_sync_at", "updated_at", "version"]) | |
| 116 | + except Exception: | |
| 117 | + logger.exception("Failed to check upstream for %s", repo.filename) | |
| 69 | 118 |
| --- fossil/tasks.py | |
| +++ fossil/tasks.py | |
| @@ -64,5 +64,54 @@ | |
| 64 | note=note, |
| 65 | created_by=repo.created_by, |
| 66 | ) |
| 67 | snapshot.file.save(f"{repo.filename}_{sha[:8]}.fossil", ContentFile(data), save=True) |
| 68 | logger.info("Created snapshot for %s (hash: %s)", repo.filename, sha[:8]) |
| 69 |
| --- fossil/tasks.py | |
| +++ fossil/tasks.py | |
| @@ -64,5 +64,54 @@ | |
| 64 | note=note, |
| 65 | created_by=repo.created_by, |
| 66 | ) |
| 67 | snapshot.file.save(f"{repo.filename}_{sha[:8]}.fossil", ContentFile(data), save=True) |
| 68 | logger.info("Created snapshot for %s (hash: %s)", repo.filename, sha[:8]) |
| 69 | |
| 70 | |
| 71 | @shared_task(name="fossil.check_upstream") |
| 72 | def check_upstream_updates(): |
| 73 | """Check all repos with remote URLs for available updates.""" |
| 74 | from fossil.cli import FossilCLI |
| 75 | from fossil.models import FossilRepository |
| 76 | |
| 77 | cli = FossilCLI() |
| 78 | if not cli.is_available(): |
| 79 | return |
| 80 | |
| 81 | from django.utils import timezone |
| 82 | |
| 83 | for repo in FossilRepository.objects.exclude(remote_url=""): |
| 84 | if not repo.exists_on_disk: |
| 85 | continue |
| 86 | try: |
| 87 | result = cli.pull(repo.full_path) |
| 88 | if result["success"] and result["artifacts_received"] > 0: |
| 89 | repo.upstream_artifacts_available = result["artifacts_received"] |
| 90 | repo.last_sync_at = timezone.now() |
| 91 | # Update metadata after pull |
| 92 | from fossil.reader import FossilReader |
| 93 | |
| 94 | with FossilReader(repo.full_path) as reader: |
| 95 | repo.checkin_count = reader.get_checkin_count() |
| 96 | timeline = reader.get_timeline(limit=1, event_type="ci") |
| 97 | if timeline: |
| 98 | repo.last_checkin_at = timeline[0].timestamp |
| 99 | repo.file_size_bytes = repo.full_path.stat().st_size |
| 100 | repo.save( |
| 101 | update_fields=[ |
| 102 | "upstream_artifacts_available", |
| 103 | "last_sync_at", |
| 104 | "checkin_count", |
| 105 | "last_checkin_at", |
| 106 | "file_size_bytes", |
| 107 | "updated_at", |
| 108 | "version", |
| 109 | ] |
| 110 | ) |
| 111 | logger.info("Pulled %d artifacts for %s (new count: %d)", result["artifacts_received"], repo.filename, repo.checkin_count) |
| 112 | else: |
| 113 | repo.upstream_artifacts_available = 0 |
| 114 | repo.last_sync_at = timezone.now() |
| 115 | repo.save(update_fields=["upstream_artifacts_available", "last_sync_at", "updated_at", "version"]) |
| 116 | except Exception: |
| 117 | logger.exception("Failed to check upstream for %s", repo.filename) |
| 118 |
| --- fossil/urls.py | ||
| +++ fossil/urls.py | ||
| @@ -24,10 +24,11 @@ | ||
| 24 | 24 | path("tags/", views.tag_list, name="tags"), |
| 25 | 25 | path("technotes/", views.technote_list, name="technotes"), |
| 26 | 26 | path("search/", views.search, name="search"), |
| 27 | 27 | path("stats/", views.repo_stats, name="stats"), |
| 28 | 28 | path("compare/", views.compare_checkins, name="compare"), |
| 29 | + path("sync/", views.sync_pull, name="sync"), | |
| 29 | 30 | path("code/raw/<path:filepath>", views.code_raw, name="code_raw"), |
| 30 | 31 | path("code/blame/<path:filepath>", views.code_blame, name="code_blame"), |
| 31 | 32 | path("code/history/<path:filepath>", views.file_history, name="file_history"), |
| 32 | 33 | path("timeline/rss/", views.timeline_rss, name="timeline_rss"), |
| 33 | 34 | path("tickets/export/", views.tickets_csv, name="tickets_csv"), |
| 34 | 35 |
| --- fossil/urls.py | |
| +++ fossil/urls.py | |
| @@ -24,10 +24,11 @@ | |
| 24 | path("tags/", views.tag_list, name="tags"), |
| 25 | path("technotes/", views.technote_list, name="technotes"), |
| 26 | path("search/", views.search, name="search"), |
| 27 | path("stats/", views.repo_stats, name="stats"), |
| 28 | path("compare/", views.compare_checkins, name="compare"), |
| 29 | path("code/raw/<path:filepath>", views.code_raw, name="code_raw"), |
| 30 | path("code/blame/<path:filepath>", views.code_blame, name="code_blame"), |
| 31 | path("code/history/<path:filepath>", views.file_history, name="file_history"), |
| 32 | path("timeline/rss/", views.timeline_rss, name="timeline_rss"), |
| 33 | path("tickets/export/", views.tickets_csv, name="tickets_csv"), |
| 34 |
| --- fossil/urls.py | |
| +++ fossil/urls.py | |
| @@ -24,10 +24,11 @@ | |
| 24 | path("tags/", views.tag_list, name="tags"), |
| 25 | path("technotes/", views.technote_list, name="technotes"), |
| 26 | path("search/", views.search, name="search"), |
| 27 | path("stats/", views.repo_stats, name="stats"), |
| 28 | path("compare/", views.compare_checkins, name="compare"), |
| 29 | path("sync/", views.sync_pull, name="sync"), |
| 30 | path("code/raw/<path:filepath>", views.code_raw, name="code_raw"), |
| 31 | path("code/blame/<path:filepath>", views.code_blame, name="code_blame"), |
| 32 | path("code/history/<path:filepath>", views.file_history, name="file_history"), |
| 33 | path("timeline/rss/", views.timeline_rss, name="timeline_rss"), |
| 34 | path("tickets/export/", views.tickets_csv, name="tickets_csv"), |
| 35 |
| --- fossil/views.py | ||
| +++ fossil/views.py | ||
| @@ -897,10 +897,82 @@ | ||
| 897 | 897 | "heatmap_json": heatmap_json, |
| 898 | 898 | "active_tab": "timeline", |
| 899 | 899 | }, |
| 900 | 900 | ) |
| 901 | 901 | |
| 902 | + | |
| 903 | +# --- Sync --- | |
| 904 | + | |
| 905 | + | |
| 906 | +@login_required | |
| 907 | +def sync_pull(request, slug): | |
| 908 | + """Pull updates from upstream remote.""" | |
| 909 | + P.PROJECT_CHANGE.check(request.user) | |
| 910 | + project, fossil_repo, reader = _get_repo_and_reader(slug) | |
| 911 | + | |
| 912 | + result = None | |
| 913 | + if request.method == "POST": | |
| 914 | + from fossil.cli import FossilCLI | |
| 915 | + | |
| 916 | + cli = FossilCLI() | |
| 917 | + if cli.is_available(): | |
| 918 | + # Detect remote URL if not set | |
| 919 | + if not fossil_repo.remote_url: | |
| 920 | + remote = cli.get_remote_url(fossil_repo.full_path) | |
| 921 | + if remote: | |
| 922 | + fossil_repo.remote_url = remote | |
| 923 | + fossil_repo.save(update_fields=["remote_url", "updated_at", "version"]) | |
| 924 | + | |
| 925 | + result = cli.pull(fossil_repo.full_path) | |
| 926 | + if result["success"]: | |
| 927 | + from django.utils import timezone | |
| 928 | + | |
| 929 | + fossil_repo.last_sync_at = timezone.now() | |
| 930 | + if result["artifacts_received"] > 0: | |
| 931 | + # Update metadata | |
| 932 | + with reader: | |
| 933 | + fossil_repo.checkin_count = reader.get_checkin_count() | |
| 934 | + fossil_repo.file_size_bytes = fossil_repo.full_path.stat().st_size | |
| 935 | + fossil_repo.upstream_artifacts_available = 0 | |
| 936 | + fossil_repo.save( | |
| 937 | + update_fields=[ | |
| 938 | + "last_sync_at", | |
| 939 | + "checkin_count", | |
| 940 | + "file_size_bytes", | |
| 941 | + "upstream_artifacts_available", | |
| 942 | + "updated_at", | |
| 943 | + "version", | |
| 944 | + ] | |
| 945 | + ) | |
| 946 | + from django.contrib import messages | |
| 947 | + | |
| 948 | + if result["artifacts_received"] > 0: | |
| 949 | + messages.success(request, f"Pulled {result['artifacts_received']} new artifacts from upstream.") | |
| 950 | + else: | |
| 951 | + messages.info(request, "Already up to date.") | |
| 952 | + | |
| 953 | + # Get remote URL for display | |
| 954 | + remote_url = fossil_repo.remote_url | |
| 955 | + if not remote_url: | |
| 956 | + from fossil.cli import FossilCLI | |
| 957 | + | |
| 958 | + cli = FossilCLI() | |
| 959 | + if cli.is_available(): | |
| 960 | + remote_url = cli.get_remote_url(fossil_repo.full_path) | |
| 961 | + | |
| 962 | + return render( | |
| 963 | + request, | |
| 964 | + "fossil/sync.html", | |
| 965 | + { | |
| 966 | + "project": project, | |
| 967 | + "fossil_repo": fossil_repo, | |
| 968 | + "remote_url": remote_url, | |
| 969 | + "result": result, | |
| 970 | + "active_tab": "code", | |
| 971 | + }, | |
| 972 | + ) | |
| 973 | + | |
| 902 | 974 | |
| 903 | 975 | # --- Technotes --- |
| 904 | 976 | |
| 905 | 977 | |
| 906 | 978 | @login_required |
| 907 | 979 |
| --- fossil/views.py | |
| +++ fossil/views.py | |
| @@ -897,10 +897,82 @@ | |
| 897 | "heatmap_json": heatmap_json, |
| 898 | "active_tab": "timeline", |
| 899 | }, |
| 900 | ) |
| 901 | |
| 902 | |
| 903 | # --- Technotes --- |
| 904 | |
| 905 | |
| 906 | @login_required |
| 907 |
| --- fossil/views.py | |
| +++ fossil/views.py | |
| @@ -897,10 +897,82 @@ | |
| 897 | "heatmap_json": heatmap_json, |
| 898 | "active_tab": "timeline", |
| 899 | }, |
| 900 | ) |
| 901 | |
| 902 | |
| 903 | # --- Sync --- |
| 904 | |
| 905 | |
| 906 | @login_required |
| 907 | def sync_pull(request, slug): |
| 908 | """Pull updates from upstream remote.""" |
| 909 | P.PROJECT_CHANGE.check(request.user) |
| 910 | project, fossil_repo, reader = _get_repo_and_reader(slug) |
| 911 | |
| 912 | result = None |
| 913 | if request.method == "POST": |
| 914 | from fossil.cli import FossilCLI |
| 915 | |
| 916 | cli = FossilCLI() |
| 917 | if cli.is_available(): |
| 918 | # Detect remote URL if not set |
| 919 | if not fossil_repo.remote_url: |
| 920 | remote = cli.get_remote_url(fossil_repo.full_path) |
| 921 | if remote: |
| 922 | fossil_repo.remote_url = remote |
| 923 | fossil_repo.save(update_fields=["remote_url", "updated_at", "version"]) |
| 924 | |
| 925 | result = cli.pull(fossil_repo.full_path) |
| 926 | if result["success"]: |
| 927 | from django.utils import timezone |
| 928 | |
| 929 | fossil_repo.last_sync_at = timezone.now() |
| 930 | if result["artifacts_received"] > 0: |
| 931 | # Update metadata |
| 932 | with reader: |
| 933 | fossil_repo.checkin_count = reader.get_checkin_count() |
| 934 | fossil_repo.file_size_bytes = fossil_repo.full_path.stat().st_size |
| 935 | fossil_repo.upstream_artifacts_available = 0 |
| 936 | fossil_repo.save( |
| 937 | update_fields=[ |
| 938 | "last_sync_at", |
| 939 | "checkin_count", |
| 940 | "file_size_bytes", |
| 941 | "upstream_artifacts_available", |
| 942 | "updated_at", |
| 943 | "version", |
| 944 | ] |
| 945 | ) |
| 946 | from django.contrib import messages |
| 947 | |
| 948 | if result["artifacts_received"] > 0: |
| 949 | messages.success(request, f"Pulled {result['artifacts_received']} new artifacts from upstream.") |
| 950 | else: |
| 951 | messages.info(request, "Already up to date.") |
| 952 | |
| 953 | # Get remote URL for display |
| 954 | remote_url = fossil_repo.remote_url |
| 955 | if not remote_url: |
| 956 | from fossil.cli import FossilCLI |
| 957 | |
| 958 | cli = FossilCLI() |
| 959 | if cli.is_available(): |
| 960 | remote_url = cli.get_remote_url(fossil_repo.full_path) |
| 961 | |
| 962 | return render( |
| 963 | request, |
| 964 | "fossil/sync.html", |
| 965 | { |
| 966 | "project": project, |
| 967 | "fossil_repo": fossil_repo, |
| 968 | "remote_url": remote_url, |
| 969 | "result": result, |
| 970 | "active_tab": "code", |
| 971 | }, |
| 972 | ) |
| 973 | |
| 974 | |
| 975 | # --- Technotes --- |
| 976 | |
| 977 | |
| 978 | @login_required |
| 979 |
| --- templates/fossil/_project_nav.html | ||
| +++ templates/fossil/_project_nav.html | ||
| @@ -25,6 +25,12 @@ | ||
| 25 | 25 | </a> |
| 26 | 26 | <a href="{% url 'fossil:forum' slug=project.slug %}" |
| 27 | 27 | class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'forum' %}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 %}"> |
| 28 | 28 | Forum |
| 29 | 29 | </a> |
| 30 | + {% if perms.projects.change_project %} | |
| 31 | + <a href="{% url 'fossil:sync' slug=project.slug %}" | |
| 32 | + 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 %}"> | |
| 33 | + Sync | |
| 34 | + </a> | |
| 35 | + {% endif %} | |
| 30 | 36 | </nav> |
| 31 | 37 | |
| 32 | 38 | ADDED templates/fossil/sync.html |
| --- templates/fossil/_project_nav.html | |
| +++ templates/fossil/_project_nav.html | |
| @@ -25,6 +25,12 @@ | |
| 25 | </a> |
| 26 | <a href="{% url 'fossil:forum' slug=project.slug %}" |
| 27 | class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'forum' %}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 %}"> |
| 28 | Forum |
| 29 | </a> |
| 30 | </nav> |
| 31 | |
| 32 | DDED templates/fossil/sync.html |
| --- templates/fossil/_project_nav.html | |
| +++ templates/fossil/_project_nav.html | |
| @@ -25,6 +25,12 @@ | |
| 25 | </a> |
| 26 | <a href="{% url 'fossil:forum' slug=project.slug %}" |
| 27 | class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'forum' %}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 %}"> |
| 28 | Forum |
| 29 | </a> |
| 30 | {% if perms.projects.change_project %} |
| 31 | <a href="{% url 'fossil:sync' slug=project.slug %}" |
| 32 | 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 %}"> |
| 33 | Sync |
| 34 | </a> |
| 35 | {% endif %} |
| 36 | </nav> |
| 37 | |
| 38 | DDED templates/fossil/sync.html |
| --- a/templates/fossil/sync.html | ||
| +++ b/templates/fossil/sync.html | ||
| @@ -0,0 +1,16 @@ | ||
| 1 | +2xl">──────── #} | |
| 2 | + {!-- Sync is configured — show5x-4 py-2 text-sm font-seflex items-center justify-betweentems-center justify-between mb-4"> | |
| 3 | + <h2 class="text-lg font-semibold text-gray-200">Upstream Sync</h2> | |
| 4 | + <span class="inline-flex�──────2xl">───────�5 mb-6"er justify-between"> | |
| 5 | + <dt class="text-sm text-gray-400">Last synced</dt> | |
| 6 | + <dd class="text-sm text-gray-200">{% if fosgray-200">{{ fossil font-mono">{{ remote_url|def"No remote configured"Bc@ES,K@jF,C:mote_url %} | |
| 7 | +R@aU,L@15G,T@1Tj,7:inline-I@BV,1h@TU,1c@VE,3P@Wu,19:</svg> | |
| 8 | + Pull from Upstream | |
| 9 | + </button> | |
| 10 | + </form> | |
| 11 | + {% else %} | |
| 12 | + <pL@1S0,1B:gray-500">No remote URL configured. This repository was created locally.</pM@tS,R@jU,5:mt-4 k@ju,1:3j@ke,1:33M@lO,1: | |
| 13 | +7@_w,1: q@oj,3: | |
| 14 | + N@qC,2: J@pi,R@1HP,1g@qI,5: | |
| 15 | + 1C@ry,1: | |
| 16 | +I@1HU,g@1Wj,3hZro; |
| --- a/templates/fossil/sync.html | |
| +++ b/templates/fossil/sync.html | |
| @@ -0,0 +1,16 @@ | |
| --- a/templates/fossil/sync.html | |
| +++ b/templates/fossil/sync.html | |
| @@ -0,0 +1,16 @@ | |
| 1 | 2xl">──────── #} |
| 2 | {!-- Sync is configured — show5x-4 py-2 text-sm font-seflex items-center justify-betweentems-center justify-between mb-4"> |
| 3 | <h2 class="text-lg font-semibold text-gray-200">Upstream Sync</h2> |
| 4 | <span class="inline-flex�──────2xl">───────�5 mb-6"er justify-between"> |
| 5 | <dt class="text-sm text-gray-400">Last synced</dt> |
| 6 | <dd class="text-sm text-gray-200">{% if fosgray-200">{{ fossil font-mono">{{ remote_url|def"No remote configured"Bc@ES,K@jF,C:mote_url %} |
| 7 | R@aU,L@15G,T@1Tj,7:inline-I@BV,1h@TU,1c@VE,3P@Wu,19:</svg> |
| 8 | Pull from Upstream |
| 9 | </button> |
| 10 | </form> |
| 11 | {% else %} |
| 12 | <pL@1S0,1B:gray-500">No remote URL configured. This repository was created locally.</pM@tS,R@jU,5:mt-4 k@ju,1:3j@ke,1:33M@lO,1: |
| 13 | 7@_w,1: q@oj,3: |
| 14 | N@qC,2: J@pi,R@1HP,1g@qI,5: |
| 15 | 1C@ry,1: |
| 16 | I@1HU,g@1Wj,3hZro; |