FossilRepo
Add push and bidirectional sync to Fossil remote CLI: push() and sync() methods wrapping fossil push/sync commands. View: push and sync_bidirectional actions on the sync page alongside existing pull. Template: Pull/Push/Sync buttons with distinct icons.
Commit
23ff229f2427bf49a1ce80804999aa73c662a3dfc97842c02d51fc2372af311e
Parent
20d32590ff7f85d…
3 files changed
+28
+35
+24
-4
+28
| --- fossil/cli.py | ||
| +++ fossil/cli.py | ||
| @@ -165,10 +165,38 @@ | ||
| 165 | 165 | finally: |
| 166 | 166 | import shutil |
| 167 | 167 | |
| 168 | 168 | shutil.rmtree(tmpdir, ignore_errors=True) |
| 169 | 169 | return lines |
| 170 | + | |
| 171 | + def push(self, repo_path: Path, remote_url: str = "") -> dict: | |
| 172 | + """Push to the remote. Returns {success, artifacts_sent, message}.""" | |
| 173 | + import re | |
| 174 | + | |
| 175 | + cmd = [self.binary, "push", "-R", str(repo_path)] | |
| 176 | + if remote_url: | |
| 177 | + cmd.append(remote_url) | |
| 178 | + try: | |
| 179 | + result = subprocess.run(cmd, capture_output=True, text=True, timeout=120, env=self._env) | |
| 180 | + artifacts = 0 | |
| 181 | + m = re.search(r"sent:\s*(\d+)", result.stdout) | |
| 182 | + if m: | |
| 183 | + artifacts = int(m.group(1)) | |
| 184 | + return {"success": result.returncode == 0, "artifacts_sent": artifacts, "message": result.stdout.strip()} | |
| 185 | + except (FileNotFoundError, subprocess.TimeoutExpired) as e: | |
| 186 | + return {"success": False, "artifacts_sent": 0, "message": str(e)} | |
| 187 | + | |
| 188 | + def sync(self, repo_path: Path, remote_url: str = "") -> dict: | |
| 189 | + """Bidirectional sync with remote. Returns {success, message}.""" | |
| 190 | + cmd = [self.binary, "sync", "-R", str(repo_path)] | |
| 191 | + if remote_url: | |
| 192 | + cmd.append(remote_url) | |
| 193 | + try: | |
| 194 | + result = subprocess.run(cmd, capture_output=True, text=True, timeout=120, env=self._env) | |
| 195 | + return {"success": result.returncode == 0, "message": result.stdout.strip()} | |
| 196 | + except (FileNotFoundError, subprocess.TimeoutExpired) as e: | |
| 197 | + return {"success": False, "message": str(e)} | |
| 170 | 198 | |
| 171 | 199 | def pull(self, repo_path: Path) -> dict: |
| 172 | 200 | """Pull updates from the remote. Returns {success, artifacts_received, message}.""" |
| 173 | 201 | try: |
| 174 | 202 | result = subprocess.run( |
| 175 | 203 |
| --- fossil/cli.py | |
| +++ fossil/cli.py | |
| @@ -165,10 +165,38 @@ | |
| 165 | finally: |
| 166 | import shutil |
| 167 | |
| 168 | shutil.rmtree(tmpdir, ignore_errors=True) |
| 169 | return lines |
| 170 | |
| 171 | def pull(self, repo_path: Path) -> dict: |
| 172 | """Pull updates from the remote. Returns {success, artifacts_received, message}.""" |
| 173 | try: |
| 174 | result = subprocess.run( |
| 175 |
| --- fossil/cli.py | |
| +++ fossil/cli.py | |
| @@ -165,10 +165,38 @@ | |
| 165 | finally: |
| 166 | import shutil |
| 167 | |
| 168 | shutil.rmtree(tmpdir, ignore_errors=True) |
| 169 | return lines |
| 170 | |
| 171 | def push(self, repo_path: Path, remote_url: str = "") -> dict: |
| 172 | """Push to the remote. Returns {success, artifacts_sent, message}.""" |
| 173 | import re |
| 174 | |
| 175 | cmd = [self.binary, "push", "-R", str(repo_path)] |
| 176 | if remote_url: |
| 177 | cmd.append(remote_url) |
| 178 | try: |
| 179 | result = subprocess.run(cmd, capture_output=True, text=True, timeout=120, env=self._env) |
| 180 | artifacts = 0 |
| 181 | m = re.search(r"sent:\s*(\d+)", result.stdout) |
| 182 | if m: |
| 183 | artifacts = int(m.group(1)) |
| 184 | return {"success": result.returncode == 0, "artifacts_sent": artifacts, "message": result.stdout.strip()} |
| 185 | except (FileNotFoundError, subprocess.TimeoutExpired) as e: |
| 186 | return {"success": False, "artifacts_sent": 0, "message": str(e)} |
| 187 | |
| 188 | def sync(self, repo_path: Path, remote_url: str = "") -> dict: |
| 189 | """Bidirectional sync with remote. Returns {success, message}.""" |
| 190 | cmd = [self.binary, "sync", "-R", str(repo_path)] |
| 191 | if remote_url: |
| 192 | cmd.append(remote_url) |
| 193 | try: |
| 194 | result = subprocess.run(cmd, capture_output=True, text=True, timeout=120, env=self._env) |
| 195 | return {"success": result.returncode == 0, "message": result.stdout.strip()} |
| 196 | except (FileNotFoundError, subprocess.TimeoutExpired) as e: |
| 197 | return {"success": False, "message": str(e)} |
| 198 | |
| 199 | def pull(self, repo_path: Path) -> dict: |
| 200 | """Pull updates from the remote. Returns {success, artifacts_received, message}.""" |
| 201 | try: |
| 202 | result = subprocess.run( |
| 203 |
+35
| --- fossil/views.py | ||
| +++ fossil/views.py | ||
| @@ -1450,10 +1450,45 @@ | ||
| 1450 | 1450 | messages.info(request, "Sync disabled.") |
| 1451 | 1451 | from django.shortcuts import redirect |
| 1452 | 1452 | |
| 1453 | 1453 | return redirect("fossil:sync", slug=slug) |
| 1454 | 1454 | |
| 1455 | + elif action == "push" and fossil_repo.remote_url: | |
| 1456 | + if cli.is_available(): | |
| 1457 | + cli.ensure_default_user(fossil_repo.full_path) | |
| 1458 | + result = cli.push(fossil_repo.full_path) | |
| 1459 | + from django.contrib import messages | |
| 1460 | + | |
| 1461 | + if result["success"]: | |
| 1462 | + from django.utils import timezone | |
| 1463 | + | |
| 1464 | + fossil_repo.last_sync_at = timezone.now() | |
| 1465 | + fossil_repo.save(update_fields=["last_sync_at", "updated_at", "version"]) | |
| 1466 | + if result.get("artifacts_sent", 0) > 0: | |
| 1467 | + messages.success(request, f"Pushed {result['artifacts_sent']} artifacts to remote.") | |
| 1468 | + else: | |
| 1469 | + messages.info(request, "Remote is already up to date.") | |
| 1470 | + else: | |
| 1471 | + messages.error(request, f"Push failed: {result.get('message', 'Unknown error')}") | |
| 1472 | + | |
| 1473 | + elif action == "sync_bidirectional" and fossil_repo.remote_url: | |
| 1474 | + if cli.is_available(): | |
| 1475 | + cli.ensure_default_user(fossil_repo.full_path) | |
| 1476 | + result = cli.sync(fossil_repo.full_path) | |
| 1477 | + from django.contrib import messages | |
| 1478 | + from django.utils import timezone | |
| 1479 | + | |
| 1480 | + if result["success"]: | |
| 1481 | + fossil_repo.last_sync_at = timezone.now() | |
| 1482 | + with reader: | |
| 1483 | + fossil_repo.checkin_count = reader.get_checkin_count() | |
| 1484 | + fossil_repo.file_size_bytes = fossil_repo.full_path.stat().st_size | |
| 1485 | + fossil_repo.save(update_fields=["last_sync_at", "checkin_count", "file_size_bytes", "updated_at", "version"]) | |
| 1486 | + messages.success(request, "Bidirectional sync complete.") | |
| 1487 | + else: | |
| 1488 | + messages.error(request, f"Sync failed: {result.get('message', 'Unknown error')}") | |
| 1489 | + | |
| 1455 | 1490 | elif action == "pull" and fossil_repo.remote_url: |
| 1456 | 1491 | if cli.is_available(): |
| 1457 | 1492 | cli.ensure_default_user(fossil_repo.full_path) |
| 1458 | 1493 | result = cli.pull(fossil_repo.full_path) |
| 1459 | 1494 | if result["success"]: |
| 1460 | 1495 |
| --- fossil/views.py | |
| +++ fossil/views.py | |
| @@ -1450,10 +1450,45 @@ | |
| 1450 | messages.info(request, "Sync disabled.") |
| 1451 | from django.shortcuts import redirect |
| 1452 | |
| 1453 | return redirect("fossil:sync", slug=slug) |
| 1454 | |
| 1455 | elif action == "pull" and fossil_repo.remote_url: |
| 1456 | if cli.is_available(): |
| 1457 | cli.ensure_default_user(fossil_repo.full_path) |
| 1458 | result = cli.pull(fossil_repo.full_path) |
| 1459 | if result["success"]: |
| 1460 |
| --- fossil/views.py | |
| +++ fossil/views.py | |
| @@ -1450,10 +1450,45 @@ | |
| 1450 | messages.info(request, "Sync disabled.") |
| 1451 | from django.shortcuts import redirect |
| 1452 | |
| 1453 | return redirect("fossil:sync", slug=slug) |
| 1454 | |
| 1455 | elif action == "push" and fossil_repo.remote_url: |
| 1456 | if cli.is_available(): |
| 1457 | cli.ensure_default_user(fossil_repo.full_path) |
| 1458 | result = cli.push(fossil_repo.full_path) |
| 1459 | from django.contrib import messages |
| 1460 | |
| 1461 | if result["success"]: |
| 1462 | from django.utils import timezone |
| 1463 | |
| 1464 | fossil_repo.last_sync_at = timezone.now() |
| 1465 | fossil_repo.save(update_fields=["last_sync_at", "updated_at", "version"]) |
| 1466 | if result.get("artifacts_sent", 0) > 0: |
| 1467 | messages.success(request, f"Pushed {result['artifacts_sent']} artifacts to remote.") |
| 1468 | else: |
| 1469 | messages.info(request, "Remote is already up to date.") |
| 1470 | else: |
| 1471 | messages.error(request, f"Push failed: {result.get('message', 'Unknown error')}") |
| 1472 | |
| 1473 | elif action == "sync_bidirectional" and fossil_repo.remote_url: |
| 1474 | if cli.is_available(): |
| 1475 | cli.ensure_default_user(fossil_repo.full_path) |
| 1476 | result = cli.sync(fossil_repo.full_path) |
| 1477 | from django.contrib import messages |
| 1478 | from django.utils import timezone |
| 1479 | |
| 1480 | if result["success"]: |
| 1481 | fossil_repo.last_sync_at = timezone.now() |
| 1482 | with reader: |
| 1483 | fossil_repo.checkin_count = reader.get_checkin_count() |
| 1484 | fossil_repo.file_size_bytes = fossil_repo.full_path.stat().st_size |
| 1485 | fossil_repo.save(update_fields=["last_sync_at", "checkin_count", "file_size_bytes", "updated_at", "version"]) |
| 1486 | messages.success(request, "Bidirectional sync complete.") |
| 1487 | else: |
| 1488 | messages.error(request, f"Sync failed: {result.get('message', 'Unknown error')}") |
| 1489 | |
| 1490 | elif action == "pull" and fossil_repo.remote_url: |
| 1491 | if cli.is_available(): |
| 1492 | cli.ensure_default_user(fossil_repo.full_path) |
| 1493 | result = cli.pull(fossil_repo.full_path) |
| 1494 | if result["success"]: |
| 1495 |
+24
-4
| --- templates/fossil/sync.html | ||
| +++ templates/fossil/sync.html | ||
| @@ -31,26 +31,46 @@ | ||
| 31 | 31 | <dt class="text-sm text-gray-400">Repository size</dt> |
| 32 | 32 | <dd class="text-sm text-gray-200">{{ fossil_repo.file_size_bytes|filesizeformat }}</dd> |
| 33 | 33 | </div> |
| 34 | 34 | </dl> |
| 35 | 35 | |
| 36 | - <div class="mt-5 flex items-center gap-3"> | |
| 36 | + <div class="mt-5 flex items-center gap-3 flex-wrap"> | |
| 37 | 37 | <form method="post"> |
| 38 | 38 | {% csrf_token %} |
| 39 | 39 | <input type="hidden" name="action" value="pull"> |
| 40 | 40 | <button type="submit" class="inline-flex items-center gap-2 rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> |
| 41 | 41 | <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 42 | + <path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" /> | |
| 43 | + </svg> | |
| 44 | + Pull | |
| 45 | + </button> | |
| 46 | + </form> | |
| 47 | + <form method="post"> | |
| 48 | + {% csrf_token %} | |
| 49 | + <input type="hidden" name="action" value="push"> | |
| 50 | + <button type="submit" class="inline-flex items-center gap-2 rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-200 shadow-sm hover:bg-gray-600 ring-1 ring-gray-600"> | |
| 51 | + <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> | |
| 52 | + <path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" /> | |
| 53 | + </svg> | |
| 54 | + Push | |
| 55 | + </button> | |
| 56 | + </form> | |
| 57 | + <form method="post"> | |
| 58 | + {% csrf_token %} | |
| 59 | + <input type="hidden" name="action" value="sync_bidirectional"> | |
| 60 | + <button type="submit" class="inline-flex items-center gap-2 rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-200 shadow-sm hover:bg-gray-600 ring-1 ring-gray-600"> | |
| 61 | + <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> | |
| 42 | 62 | <path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182" /> |
| 43 | 63 | </svg> |
| 44 | - Pull from Upstream | |
| 64 | + Sync | |
| 45 | 65 | </button> |
| 46 | 66 | </form> |
| 47 | 67 | <form method="post"> |
| 48 | 68 | {% csrf_token %} |
| 49 | 69 | <input type="hidden" name="action" value="disable"> |
| 50 | - <button type="submit" class="rounded-md bg-gray-700 px-3 py-2 text-sm text-gray-400 hover:text-white ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> | |
| 51 | - Disable Sync | |
| 70 | + <button type="submit" class="rounded-md bg-gray-800 px-3 py-2 text-sm text-gray-500 hover:text-gray-300 ring-1 ring-inset ring-gray-700 hover:bg-gray-700"> | |
| 71 | + Disable | |
| 52 | 72 | </button> |
| 53 | 73 | </form> |
| 54 | 74 | </div> |
| 55 | 75 | </div> |
| 56 | 76 | |
| 57 | 77 |
| --- templates/fossil/sync.html | |
| +++ templates/fossil/sync.html | |
| @@ -31,26 +31,46 @@ | |
| 31 | <dt class="text-sm text-gray-400">Repository size</dt> |
| 32 | <dd class="text-sm text-gray-200">{{ fossil_repo.file_size_bytes|filesizeformat }}</dd> |
| 33 | </div> |
| 34 | </dl> |
| 35 | |
| 36 | <div class="mt-5 flex items-center gap-3"> |
| 37 | <form method="post"> |
| 38 | {% csrf_token %} |
| 39 | <input type="hidden" name="action" value="pull"> |
| 40 | <button type="submit" class="inline-flex items-center gap-2 rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> |
| 41 | <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 42 | <path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182" /> |
| 43 | </svg> |
| 44 | Pull from Upstream |
| 45 | </button> |
| 46 | </form> |
| 47 | <form method="post"> |
| 48 | {% csrf_token %} |
| 49 | <input type="hidden" name="action" value="disable"> |
| 50 | <button type="submit" class="rounded-md bg-gray-700 px-3 py-2 text-sm text-gray-400 hover:text-white ring-1 ring-inset ring-gray-600 hover:bg-gray-600"> |
| 51 | Disable Sync |
| 52 | </button> |
| 53 | </form> |
| 54 | </div> |
| 55 | </div> |
| 56 | |
| 57 |
| --- templates/fossil/sync.html | |
| +++ templates/fossil/sync.html | |
| @@ -31,26 +31,46 @@ | |
| 31 | <dt class="text-sm text-gray-400">Repository size</dt> |
| 32 | <dd class="text-sm text-gray-200">{{ fossil_repo.file_size_bytes|filesizeformat }}</dd> |
| 33 | </div> |
| 34 | </dl> |
| 35 | |
| 36 | <div class="mt-5 flex items-center gap-3 flex-wrap"> |
| 37 | <form method="post"> |
| 38 | {% csrf_token %} |
| 39 | <input type="hidden" name="action" value="pull"> |
| 40 | <button type="submit" class="inline-flex items-center gap-2 rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> |
| 41 | <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 42 | <path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" /> |
| 43 | </svg> |
| 44 | Pull |
| 45 | </button> |
| 46 | </form> |
| 47 | <form method="post"> |
| 48 | {% csrf_token %} |
| 49 | <input type="hidden" name="action" value="push"> |
| 50 | <button type="submit" class="inline-flex items-center gap-2 rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-200 shadow-sm hover:bg-gray-600 ring-1 ring-gray-600"> |
| 51 | <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 52 | <path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" /> |
| 53 | </svg> |
| 54 | Push |
| 55 | </button> |
| 56 | </form> |
| 57 | <form method="post"> |
| 58 | {% csrf_token %} |
| 59 | <input type="hidden" name="action" value="sync_bidirectional"> |
| 60 | <button type="submit" class="inline-flex items-center gap-2 rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-200 shadow-sm hover:bg-gray-600 ring-1 ring-gray-600"> |
| 61 | <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 62 | <path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182" /> |
| 63 | </svg> |
| 64 | Sync |
| 65 | </button> |
| 66 | </form> |
| 67 | <form method="post"> |
| 68 | {% csrf_token %} |
| 69 | <input type="hidden" name="action" value="disable"> |
| 70 | <button type="submit" class="rounded-md bg-gray-800 px-3 py-2 text-sm text-gray-500 hover:text-gray-300 ring-1 ring-inset ring-gray-700 hover:bg-gray-700"> |
| 71 | Disable |
| 72 | </button> |
| 73 | </form> |
| 74 | </div> |
| 75 | </div> |
| 76 | |
| 77 |