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.

lmata 2026-04-07 00:50 trunk
Commit 803470c846217252b0c07c828241c0b65867b1d114f27bf136d3d0f1ea25d29f
--- config/settings.py
+++ config/settings.py
@@ -192,10 +192,14 @@
192192
CELERY_BEAT_SCHEDULE = {
193193
"fossil-sync-metadata": {
194194
"task": "fossil.sync_metadata",
195195
"schedule": 300.0, # every 5 minutes
196196
},
197
+ "fossil-check-upstream": {
198
+ "task": "fossil.check_upstream",
199
+ "schedule": 900.0, # every 15 minutes
200
+ },
197201
}
198202
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True
199203
200204
# --- CORS ---
201205
202206
--- 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 @@
9999
finally:
100100
import shutil
101101
102102
shutil.rmtree(tmpdir, ignore_errors=True)
103103
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 ""
104136
105137
def wiki_commit(self, repo_path: Path, page_name: str, content: str, user: str = "") -> bool:
106138
"""Create or update a wiki page. Pipes content to fossil wiki commit."""
107139
cmd = [self.binary, "wiki", "commit", page_name, "-R", str(repo_path)]
108140
if user:
109141
110142
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 @@
1313
file_size_bytes = models.BigIntegerField(default=0)
1414
fossil_project_code = models.CharField(max_length=40, blank=True, default="")
1515
last_checkin_at = models.DateTimeField(null=True, blank=True)
1616
checkin_count = models.PositiveIntegerField(default=0)
1717
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
+
1823
# S3 tracking
1924
s3_key = models.CharField(max_length=500, blank=True, default="")
2025
s3_last_replicated_at = models.DateTimeField(null=True, blank=True)
2126
2227
objects = ActiveManager()
2328
--- 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 @@
6464
note=note,
6565
created_by=repo.created_by,
6666
)
6767
snapshot.file.save(f"{repo.filename}_{sha[:8]}.fossil", ContentFile(data), save=True)
6868
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)
69118
--- 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 @@
2424
path("tags/", views.tag_list, name="tags"),
2525
path("technotes/", views.technote_list, name="technotes"),
2626
path("search/", views.search, name="search"),
2727
path("stats/", views.repo_stats, name="stats"),
2828
path("compare/", views.compare_checkins, name="compare"),
29
+ path("sync/", views.sync_pull, name="sync"),
2930
path("code/raw/<path:filepath>", views.code_raw, name="code_raw"),
3031
path("code/blame/<path:filepath>", views.code_blame, name="code_blame"),
3132
path("code/history/<path:filepath>", views.file_history, name="file_history"),
3233
path("timeline/rss/", views.timeline_rss, name="timeline_rss"),
3334
path("tickets/export/", views.tickets_csv, name="tickets_csv"),
3435
--- 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 @@
897897
"heatmap_json": heatmap_json,
898898
"active_tab": "timeline",
899899
},
900900
)
901901
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
+
902974
903975
# --- Technotes ---
904976
905977
906978
@login_required
907979
--- 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 @@
2525
</a>
2626
<a href="{% url 'fossil:forum' slug=project.slug %}"
2727
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 %}">
2828
Forum
2929
</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 %}
3036
</nav>
3137
3238
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;

Keyboard Shortcuts

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