FossilRepo

Add Git mirror sync foundation: models, CLI, Celery task, constance settings Models (fossil/sync_models.py): - GitMirror: per-repo Git remote config (URL, auth, sync mode/schedule/direction) Auth methods: SSH key, personal token, GitHub OAuth, GitLab OAuth Sync modes: on_change, scheduled, both, disabled Sync direction: push, pull, bidirectional Branch mapping (fossil_branch → git_branch) Toggle sync_code, sync_tickets, sync_wiki - SSHKey: SSH key pair storage with fingerprint - SyncLog: audit trail for sync operations CLI (fossil/cli.py): - git_export(): runs fossil git export with --autopush for incremental sync - generate_ssh_key(): creates ed25519 key pair for Git auth Celery task: - run_git_sync(): iterates all enabled mirrors, runs fossil git export, logs results, handles token auth by injecting into HTTPS URL Constance settings: - GIT_SYNC_MODE, GIT_SYNC_SCHEDULE, GIT_MIRROR_DIR, GIT_SSH_KEY_DIR - GITHUB_OAUTH_CLIENT_ID/SECRET, GITLAB_OAUTH_CLIENT_ID/SECRET Admin: - GitMirror, SSHKey registered with inline SyncLog Ref: #13

lmata 2026-04-07 02:52 trunk
Commit 98e5656a69b2bc089edbced5ec7e63975c1c8e48587c85a3ae00e7f0c84d2ed0
--- config/settings.py
+++ config/settings.py
@@ -220,14 +220,26 @@
220220
"FOSSIL_DATA_DIR": ("/data/repos", "Directory where .fossil repository files are stored"),
221221
"FOSSIL_STORE_IN_DB": (False, "Store binary snapshots of .fossil files via Django file storage"),
222222
"FOSSIL_S3_TRACKING": (False, "Track S3/Litestream replication keys and versions"),
223223
"FOSSIL_S3_BUCKET": ("", "S3 bucket name for Fossil repo replication"),
224224
"FOSSIL_BINARY_PATH": ("fossil", "Path to the fossil binary"),
225
+ # Git sync settings
226
+ "GIT_SYNC_MODE": ("disabled", "Default sync mode: disabled, on_change, scheduled, both"),
227
+ "GIT_SYNC_SCHEDULE": ("*/15 * * * *", "Default cron schedule for Git sync"),
228
+ "GIT_MIRROR_DIR": ("/data/git-mirrors", "Directory for Git mirror checkouts"),
229
+ "GIT_SSH_KEY_DIR": ("/data/ssh-keys", "Directory for SSH key storage"),
230
+ "GITHUB_OAUTH_CLIENT_ID": ("", "GitHub OAuth App Client ID"),
231
+ "GITHUB_OAUTH_CLIENT_SECRET": ("", "GitHub OAuth App Client Secret"),
232
+ "GITLAB_OAUTH_CLIENT_ID": ("", "GitLab OAuth App Client ID"),
233
+ "GITLAB_OAUTH_CLIENT_SECRET": ("", "GitLab OAuth App Client Secret"),
225234
}
226235
CONSTANCE_CONFIG_FIELDSETS = {
227236
"General": ("SITE_NAME",),
228237
"Fossil Storage": ("FOSSIL_DATA_DIR", "FOSSIL_STORE_IN_DB", "FOSSIL_S3_TRACKING", "FOSSIL_S3_BUCKET", "FOSSIL_BINARY_PATH"),
238
+ "Git Sync": ("GIT_SYNC_MODE", "GIT_SYNC_SCHEDULE", "GIT_MIRROR_DIR", "GIT_SSH_KEY_DIR"),
239
+ "GitHub OAuth": ("GITHUB_OAUTH_CLIENT_ID", "GITHUB_OAUTH_CLIENT_SECRET"),
240
+ "GitLab OAuth": ("GITLAB_OAUTH_CLIENT_ID", "GITLAB_OAUTH_CLIENT_SECRET"),
229241
}
230242
231243
# --- Sentry ---
232244
233245
SENTRY_DSN = env_str("SENTRY_DSN")
234246
--- config/settings.py
+++ config/settings.py
@@ -220,14 +220,26 @@
220 "FOSSIL_DATA_DIR": ("/data/repos", "Directory where .fossil repository files are stored"),
221 "FOSSIL_STORE_IN_DB": (False, "Store binary snapshots of .fossil files via Django file storage"),
222 "FOSSIL_S3_TRACKING": (False, "Track S3/Litestream replication keys and versions"),
223 "FOSSIL_S3_BUCKET": ("", "S3 bucket name for Fossil repo replication"),
224 "FOSSIL_BINARY_PATH": ("fossil", "Path to the fossil binary"),
 
 
 
 
 
 
 
 
 
225 }
226 CONSTANCE_CONFIG_FIELDSETS = {
227 "General": ("SITE_NAME",),
228 "Fossil Storage": ("FOSSIL_DATA_DIR", "FOSSIL_STORE_IN_DB", "FOSSIL_S3_TRACKING", "FOSSIL_S3_BUCKET", "FOSSIL_BINARY_PATH"),
 
 
 
229 }
230
231 # --- Sentry ---
232
233 SENTRY_DSN = env_str("SENTRY_DSN")
234
--- config/settings.py
+++ config/settings.py
@@ -220,14 +220,26 @@
220 "FOSSIL_DATA_DIR": ("/data/repos", "Directory where .fossil repository files are stored"),
221 "FOSSIL_STORE_IN_DB": (False, "Store binary snapshots of .fossil files via Django file storage"),
222 "FOSSIL_S3_TRACKING": (False, "Track S3/Litestream replication keys and versions"),
223 "FOSSIL_S3_BUCKET": ("", "S3 bucket name for Fossil repo replication"),
224 "FOSSIL_BINARY_PATH": ("fossil", "Path to the fossil binary"),
225 # Git sync settings
226 "GIT_SYNC_MODE": ("disabled", "Default sync mode: disabled, on_change, scheduled, both"),
227 "GIT_SYNC_SCHEDULE": ("*/15 * * * *", "Default cron schedule for Git sync"),
228 "GIT_MIRROR_DIR": ("/data/git-mirrors", "Directory for Git mirror checkouts"),
229 "GIT_SSH_KEY_DIR": ("/data/ssh-keys", "Directory for SSH key storage"),
230 "GITHUB_OAUTH_CLIENT_ID": ("", "GitHub OAuth App Client ID"),
231 "GITHUB_OAUTH_CLIENT_SECRET": ("", "GitHub OAuth App Client Secret"),
232 "GITLAB_OAUTH_CLIENT_ID": ("", "GitLab OAuth App Client ID"),
233 "GITLAB_OAUTH_CLIENT_SECRET": ("", "GitLab OAuth App Client Secret"),
234 }
235 CONSTANCE_CONFIG_FIELDSETS = {
236 "General": ("SITE_NAME",),
237 "Fossil Storage": ("FOSSIL_DATA_DIR", "FOSSIL_STORE_IN_DB", "FOSSIL_S3_TRACKING", "FOSSIL_S3_BUCKET", "FOSSIL_BINARY_PATH"),
238 "Git Sync": ("GIT_SYNC_MODE", "GIT_SYNC_SCHEDULE", "GIT_MIRROR_DIR", "GIT_SSH_KEY_DIR"),
239 "GitHub OAuth": ("GITHUB_OAUTH_CLIENT_ID", "GITHUB_OAUTH_CLIENT_SECRET"),
240 "GitLab OAuth": ("GITLAB_OAUTH_CLIENT_ID", "GITLAB_OAUTH_CLIENT_SECRET"),
241 }
242
243 # --- Sentry ---
244
245 SENTRY_DSN = env_str("SENTRY_DSN")
246
--- fossil/admin.py
+++ fossil/admin.py
@@ -1,10 +1,11 @@
11
from django.contrib import admin
22
33
from core.admin import BaseCoreAdmin
44
55
from .models import FossilRepository, FossilSnapshot
6
+from .sync_models import GitMirror, SSHKey, SyncLog
67
78
89
class FossilSnapshotInline(admin.TabularInline):
910
model = FossilSnapshot
1011
extra = 0
@@ -21,5 +22,25 @@
2122
2223
@admin.register(FossilSnapshot)
2324
class FossilSnapshotAdmin(BaseCoreAdmin):
2425
list_display = ("repository", "file_size_bytes", "fossil_hash", "created_at")
2526
raw_id_fields = ("repository",)
27
+
28
+
29
+class SyncLogInline(admin.TabularInline):
30
+ model = SyncLog
31
+ extra = 0
32
+ readonly_fields = ("started_at", "completed_at", "status", "artifacts_synced", "triggered_by")
33
+
34
+
35
+@admin.register(GitMirror)
36
+class GitMirrorAdmin(BaseCoreAdmin):
37
+ list_display = ("repository", "git_remote_url", "sync_mode", "sync_direction", "last_sync_status", "last_sync_at")
38
+ list_filter = ("sync_mode", "sync_direction", "auth_method")
39
+ raw_id_fields = ("repository",)
40
+ inlines = [SyncLogInline]
41
+
42
+
43
+@admin.register(SSHKey)
44
+class SSHKeyAdmin(BaseCoreAdmin):
45
+ list_display = ("name", "fingerprint", "created_at")
46
+ readonly_fields = ("public_key", "fingerprint")
2647
--- fossil/admin.py
+++ fossil/admin.py
@@ -1,10 +1,11 @@
1 from django.contrib import admin
2
3 from core.admin import BaseCoreAdmin
4
5 from .models import FossilRepository, FossilSnapshot
 
6
7
8 class FossilSnapshotInline(admin.TabularInline):
9 model = FossilSnapshot
10 extra = 0
@@ -21,5 +22,25 @@
21
22 @admin.register(FossilSnapshot)
23 class FossilSnapshotAdmin(BaseCoreAdmin):
24 list_display = ("repository", "file_size_bytes", "fossil_hash", "created_at")
25 raw_id_fields = ("repository",)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
--- fossil/admin.py
+++ fossil/admin.py
@@ -1,10 +1,11 @@
1 from django.contrib import admin
2
3 from core.admin import BaseCoreAdmin
4
5 from .models import FossilRepository, FossilSnapshot
6 from .sync_models import GitMirror, SSHKey, SyncLog
7
8
9 class FossilSnapshotInline(admin.TabularInline):
10 model = FossilSnapshot
11 extra = 0
@@ -21,5 +22,25 @@
22
23 @admin.register(FossilSnapshot)
24 class FossilSnapshotAdmin(BaseCoreAdmin):
25 list_display = ("repository", "file_size_bytes", "fossil_hash", "created_at")
26 raw_id_fields = ("repository",)
27
28
29 class SyncLogInline(admin.TabularInline):
30 model = SyncLog
31 extra = 0
32 readonly_fields = ("started_at", "completed_at", "status", "artifacts_synced", "triggered_by")
33
34
35 @admin.register(GitMirror)
36 class GitMirrorAdmin(BaseCoreAdmin):
37 list_display = ("repository", "git_remote_url", "sync_mode", "sync_direction", "last_sync_status", "last_sync_at")
38 list_filter = ("sync_mode", "sync_direction", "auth_method")
39 raw_id_fields = ("repository",)
40 inlines = [SyncLogInline]
41
42
43 @admin.register(SSHKey)
44 class SSHKeyAdmin(BaseCoreAdmin):
45 list_display = ("name", "fingerprint", "created_at")
46 readonly_fields = ("public_key", "fingerprint")
47
--- fossil/cli.py
+++ fossil/cli.py
@@ -198,5 +198,51 @@
198198
for key, value in fields.items():
199199
cmd.append(f"{key}")
200200
cmd.append(f"{value}")
201201
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, env=self._env)
202202
return result.returncode == 0
203
+
204
+ def git_export(self, repo_path: Path, mirror_dir: Path, autopush_url: str = "") -> dict:
205
+ """Export Fossil repo to a Git mirror directory. Incremental.
206
+
207
+ Returns {success, message}.
208
+ """
209
+ mirror_dir.mkdir(parents=True, exist_ok=True)
210
+ cmd = [self.binary, "git", "export", str(mirror_dir), "-R", str(repo_path)]
211
+ if autopush_url:
212
+ cmd.extend(["--autopush", autopush_url])
213
+ try:
214
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=300, env=self._env)
215
+ return {"success": result.returncode == 0, "message": (result.stdout + result.stderr).strip()}
216
+ except subprocess.TimeoutExpired:
217
+ return {"success": False, "message": "Export timed out after 5 minutes"}
218
+
219
+ def generate_ssh_key(self, key_path: Path, comment: str = "fossilrepo") -> dict:
220
+ """Generate an SSH key pair for Git authentication.
221
+
222
+ Returns {success, public_key, fingerprint}.
223
+ """
224
+ import os
225
+
226
+ try:
227
+ key_path.parent.mkdir(parents=True, exist_ok=True)
228
+ result = subprocess.run(
229
+ ["ssh-keygen", "-t", "ed25519", "-f", str(key_path), "-N", "", "-C", comment],
230
+ capture_output=True,
231
+ text=True,
232
+ timeout=10,
233
+ env={**os.environ},
234
+ )
235
+ if result.returncode == 0:
236
+ pub_key = key_path.with_suffix(".pub").read_text().strip()
237
+ # Get fingerprint
238
+ fp_result = subprocess.run(
239
+ ["ssh-keygen", "-lf", str(key_path.with_suffix(".pub"))],
240
+ capture_output=True,
241
+ text=True,
242
+ timeout=10,
243
+ )
244
+ fingerprint = fp_result.stdout.strip().split()[1] if fp_result.returncode == 0 else ""
245
+ return {"success": True, "public_key": pub_key, "fingerprint": fingerprint}
246
+ except Exception as e:
247
+ return {"success": False, "public_key": "", "fingerprint": "", "error": str(e)}
248
+ return {"success": False, "public_key": "", "fingerprint": ""}
203249
204250
ADDED fossil/migrations/0003_gitmirror_historicalgitmirror_historicalsshkey_and_more.py
--- fossil/cli.py
+++ fossil/cli.py
@@ -198,5 +198,51 @@
198 for key, value in fields.items():
199 cmd.append(f"{key}")
200 cmd.append(f"{value}")
201 result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, env=self._env)
202 return result.returncode == 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
204 DDED fossil/migrations/0003_gitmirror_historicalgitmirror_historicalsshkey_and_more.py
--- fossil/cli.py
+++ fossil/cli.py
@@ -198,5 +198,51 @@
198 for key, value in fields.items():
199 cmd.append(f"{key}")
200 cmd.append(f"{value}")
201 result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, env=self._env)
202 return result.returncode == 0
203
204 def git_export(self, repo_path: Path, mirror_dir: Path, autopush_url: str = "") -> dict:
205 """Export Fossil repo to a Git mirror directory. Incremental.
206
207 Returns {success, message}.
208 """
209 mirror_dir.mkdir(parents=True, exist_ok=True)
210 cmd = [self.binary, "git", "export", str(mirror_dir), "-R", str(repo_path)]
211 if autopush_url:
212 cmd.extend(["--autopush", autopush_url])
213 try:
214 result = subprocess.run(cmd, capture_output=True, text=True, timeout=300, env=self._env)
215 return {"success": result.returncode == 0, "message": (result.stdout + result.stderr).strip()}
216 except subprocess.TimeoutExpired:
217 return {"success": False, "message": "Export timed out after 5 minutes"}
218
219 def generate_ssh_key(self, key_path: Path, comment: str = "fossilrepo") -> dict:
220 """Generate an SSH key pair for Git authentication.
221
222 Returns {success, public_key, fingerprint}.
223 """
224 import os
225
226 try:
227 key_path.parent.mkdir(parents=True, exist_ok=True)
228 result = subprocess.run(
229 ["ssh-keygen", "-t", "ed25519", "-f", str(key_path), "-N", "", "-C", comment],
230 capture_output=True,
231 text=True,
232 timeout=10,
233 env={**os.environ},
234 )
235 if result.returncode == 0:
236 pub_key = key_path.with_suffix(".pub").read_text().strip()
237 # Get fingerprint
238 fp_result = subprocess.run(
239 ["ssh-keygen", "-lf", str(key_path.with_suffix(".pub"))],
240 capture_output=True,
241 text=True,
242 timeout=10,
243 )
244 fingerprint = fp_result.stdout.strip().split()[1] if fp_result.returncode == 0 else ""
245 return {"success": True, "public_key": pub_key, "fingerprint": fingerprint}
246 except Exception as e:
247 return {"success": False, "public_key": "", "fingerprint": "", "error": str(e)}
248 return {"success": False, "public_key": "", "fingerprint": ""}
249
250 DDED fossil/migrations/0003_gitmirror_historicalgitmirror_historicalsshkey_and_more.py
--- a/fossil/migrations/0003_gitmirror_historicalgitmirror_historicalsshkey_and_more.py
+++ b/fossil/migrations/0003_gitmirror_historicalgitmirror_historicalsshkey_and_more.py
@@ -0,0 +1,514 @@
1
+# Generated by Django 5.2.12 on 2026-04-07 02:51
2
+
3
+import django.db.models.deletion
4
+import simple_history.models
5
+from django.conf import settings
6
+from django.db import migrations, models
7
+
8
+
9
+class Migration(migrations.Migration):
10
+ dependencies = [
11
+ ("fossil", "0002_fossilrepository_last_sync_at_and_more"),
12
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
13
+ ]
14
+
15
+ operations = [
16
+ migrations.CreateModel(
17
+ name="GitMirror",
18
+ fields=[
19
+ (
20
+ "id",
21
+ models.BigAutoField(
22
+ auto_created=True,
23
+ primary_key=True,
24
+ serialize=False,
25
+ verbose_name="ID",
26
+ ),
27
+ ),
28
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
29
+ ("created_at", models.DateTimeField(auto_now_add=True)),
30
+ ("updated_at", models.DateTimeField(auto_now=True)),
31
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
32
+ (
33
+ "git_remote_url",
34
+ models.CharField(help_text="Git remote URL (SSH or HTTPS)", max_length=500),
35
+ ),
36
+ (
37
+ "auth_method",
38
+ models.CharField(
39
+ choices=[
40
+ ("ssh", "SSH Key"),
41
+ ("token", "Personal Access Token"),
42
+ ("oauth_github", "GitHub OAuth"),
43
+ ("oauth_gitlab", "GitLab OAuth"),
44
+ ],
45
+ default="token",
46
+ max_length=20,
47
+ ),
48
+ ),
49
+ (
50
+ "auth_credential",
51
+ models.TextField(
52
+ blank=True,
53
+ default="",
54
+ help_text="Encrypted token or key reference",
55
+ ),
56
+ ),
57
+ (
58
+ "sync_direction",
59
+ models.CharField(
60
+ choices=[
61
+ ("push", "Push (Fossil → Git)"),
62
+ ("pull", "Pull (Git → Fossil)"),
63
+ ("both", "Bidirectional"),
64
+ ],
65
+ default="push",
66
+ max_length=10,
67
+ ),
68
+ ),
69
+ (
70
+ "sync_mode",
71
+ models.CharField(
72
+ choices=[
73
+ ("on_change", "On Change"),
74
+ ("scheduled", "Scheduled"),
75
+ ("both", "Both"),
76
+ ("disabled", "Disabled"),
77
+ ],
78
+ default="scheduled",
79
+ max_length=20,
80
+ ),
81
+ ),
82
+ (
83
+ "sync_schedule",
84
+ models.CharField(
85
+ blank=True,
86
+ default="*/15 * * * *",
87
+ help_text="Cron expression for scheduled sync",
88
+ max_length=100,
89
+ ),
90
+ ),
91
+ ("sync_code", models.BooleanField(default=True)),
92
+ (
93
+ "sync_tickets",
94
+ models.BooleanField(default=False, help_text="Sync tickets as GitHub/GitLab Issues"),
95
+ ),
96
+ (
97
+ "sync_wiki",
98
+ models.BooleanField(default=False, help_text="Sync wiki pages"),
99
+ ),
100
+ (
101
+ "fossil_branch",
102
+ models.CharField(
103
+ default="trunk",
104
+ help_text="Fossil branch to sync",
105
+ max_length=100,
106
+ ),
107
+ ),
108
+ (
109
+ "git_branch",
110
+ models.CharField(
111
+ default="main",
112
+ help_text="Git branch to push to",
113
+ max_length=100,
114
+ ),
115
+ ),
116
+ ("last_sync_at", models.DateTimeField(blank=True, null=True)),
117
+ (
118
+ "last_sync_status",
119
+ models.CharField(blank=True, default="", max_length=20),
120
+ ),
121
+ ("last_sync_message", models.TextField(blank=True, default="")),
122
+ ("total_syncs", models.PositiveIntegerField(default=0)),
123
+ (
124
+ "created_by",
125
+ models.ForeignKey(
126
+ blank=True,
127
+ null=True,
128
+ on_delete=django.db.models.deletion.SET_NULL,
129
+ related_name="+",
130
+ to=settings.AUTH_USER_MODEL,
131
+ ),
132
+ ),
133
+ (
134
+ "deleted_by",
135
+ models.ForeignKey(
136
+ blank=True,
137
+ null=True,
138
+ on_delete=django.db.models.deletion.SET_NULL,
139
+ related_name="+",
140
+ to=settings.AUTH_USER_MODEL,
141
+ ),
142
+ ),
143
+ (
144
+ "repository",
145
+ models.ForeignKey(
146
+ on_delete=django.db.models.deletion.CASCADE,
147
+ related_name="git_mirrors",
148
+ to="fossil.fossilrepository",
149
+ ),
150
+ ),
151
+ (
152
+ "updated_by",
153
+ models.ForeignKey(
154
+ blank=True,
155
+ null=True,
156
+ on_delete=django.db.models.deletion.SET_NULL,
157
+ related_name="+",
158
+ to=settings.AUTH_USER_MODEL,
159
+ ),
160
+ ),
161
+ ],
162
+ options={
163
+ "ordering": ["-created_at"],
164
+ },
165
+ ),
166
+ migrations.CreateModel(
167
+ name="HistoricalGitMirror",
168
+ fields=[
169
+ (
170
+ "id",
171
+ models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
172
+ ),
173
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
174
+ ("created_at", models.DateTimeField(blank=True, editable=False)),
175
+ ("updated_at", models.DateTimeField(blank=True, editable=False)),
176
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
177
+ (
178
+ "git_remote_url",
179
+ models.CharField(help_text="Git remote URL (SSH or HTTPS)", max_length=500),
180
+ ),
181
+ (
182
+ "auth_method",
183
+ models.CharField(
184
+ choices=[
185
+ ("ssh", "SSH Key"),
186
+ ("token", "Personal Access Token"),
187
+ ("oauth_github", "GitHub OAuth"),
188
+ ("oauth_gitlab", "GitLab OAuth"),
189
+ ],
190
+ default="token",
191
+ max_length=20,
192
+ ),
193
+ ),
194
+ (
195
+ "auth_credential",
196
+ models.TextField(
197
+ blank=True,
198
+ default="",
199
+ help_text="Encrypted token or key reference",
200
+ ),
201
+ ),
202
+ (
203
+ "sync_direction",
204
+ models.CharField(
205
+ choices=[
206
+ ("push", "Push (Fossil → Git)"),
207
+ ("pull", "Pull (Git → Fossil)"),
208
+ ("both", "Bidirectional"),
209
+ ],
210
+ default="push",
211
+ max_length=10,
212
+ ),
213
+ ),
214
+ (
215
+ "sync_mode",
216
+ models.CharField(
217
+ choices=[
218
+ ("on_change", "On Change"),
219
+ ("scheduled", "Scheduled"),
220
+ ("both", "Both"),
221
+ ("disabled", "Disabled"),
222
+ ],
223
+ default="scheduled",
224
+ max_length=20,
225
+ ),
226
+ ),
227
+ (
228
+ "sync_schedule",
229
+ models.CharField(
230
+ blank=True,
231
+ default="*/15 * * * *",
232
+ help_text="Cron expression for scheduled sync",
233
+ max_length=100,
234
+ ),
235
+ ),
236
+ ("sync_code", models.BooleanField(default=True)),
237
+ (
238
+ "sync_tickets",
239
+ models.BooleanField(default=False, help_text="Sync tickets as GitHub/GitLab Issues"),
240
+ ),
241
+ (
242
+ "sync_wiki",
243
+ models.BooleanField(default=False, help_text="Sync wiki pages"),
244
+ ),
245
+ (
246
+ "fossil_branch",
247
+ models.CharField(
248
+ default="trunk",
249
+ help_text="Fossil branch to sync",
250
+ max_length=100,
251
+ ),
252
+ ),
253
+ (
254
+ "git_branch",
255
+ models.CharField(
256
+ default="main",
257
+ help_text="Git branch to push to",
258
+ max_length=100,
259
+ ),
260
+ ),
261
+ ("last_sync_at", models.DateTimeField(blank=True, null=True)),
262
+ (
263
+ "last_sync_status",
264
+ models.CharField(blank=True, default="", max_length=20),
265
+ ),
266
+ ("last_sync_message", models.TextField(blank=True, default="")),
267
+ ("total_syncs", models.PositiveIntegerField(default=0)),
268
+ ("history_id", models.AutoField(primary_key=True, serialize=False)),
269
+ ("history_date", models.DateTimeField(db_index=True)),
270
+ ("history_change_reason", models.CharField(max_length=100, null=True)),
271
+ (
272
+ "history_type",
273
+ models.CharField(
274
+ choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
275
+ max_length=1,
276
+ ),
277
+ ),
278
+ (
279
+ "created_by",
280
+ models.ForeignKey(
281
+ blank=True,
282
+ db_constraint=False,
283
+ null=True,
284
+ on_delete=django.db.models.deletion.DO_NOTHING,
285
+ related_name="+",
286
+ to=settings.AUTH_USER_MODEL,
287
+ ),
288
+ ),
289
+ (
290
+ "deleted_by",
291
+ models.ForeignKey(
292
+ blank=True,
293
+ db_constraint=False,
294
+ null=True,
295
+ on_delete=django.db.models.deletion.DO_NOTHING,
296
+ related_name="+",
297
+ to=settings.AUTH_USER_MODEL,
298
+ ),
299
+ ),
300
+ (
301
+ "history_user",
302
+ models.ForeignKey(
303
+ null=True,
304
+ on_delete=django.db.models.deletion.SET_NULL,
305
+ related_name="+",
306
+ to=settings.AUTH_USER_MODEL,
307
+ ),
308
+ ),
309
+ (
310
+ "repository",
311
+ models.ForeignKey(
312
+ blank=True,
313
+ db_constraint=False,
314
+ null=True,
315
+ on_delete=django.db.models.deletion.DO_NOTHING,
316
+ related_name="+",
317
+ to="fossil.fossilrepository",
318
+ ),
319
+ ),
320
+ (
321
+ "updated_by",
322
+ models.ForeignKey(
323
+ blank=True,
324
+ db_constraint=False,
325
+ null=True,
326
+ on_delete=django.db.models.deletion.DO_NOTHING,
327
+ related_name="+",
328
+ to=settings.AUTH_USER_MODEL,
329
+ ),
330
+ ),
331
+ ],
332
+ options={
333
+ "verbose_name": "historical git mirror",
334
+ "verbose_name_plural": "historical git mirrors",
335
+ "ordering": ("-history_date", "-history_id"),
336
+ "get_latest_by": ("history_date", "history_id"),
337
+ },
338
+ bases=(simple_history.models.HistoricalChanges, models.Model),
339
+ ),
340
+ migrations.CreateModel(
341
+ name="HistoricalSSHKey",
342
+ fields=[
343
+ (
344
+ "id",
345
+ models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
346
+ ),
347
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
348
+ ("created_at", models.DateTimeField(blank=True, editable=False)),
349
+ ("updated_at", models.DateTimeField(blank=True, editable=False)),
350
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
351
+ ("name", models.CharField(max_length=200)),
352
+ ("public_key", models.TextField()),
353
+ (
354
+ "private_key_path",
355
+ models.CharField(help_text="Path to private key file on disk", max_length=500),
356
+ ),
357
+ (
358
+ "fingerprint",
359
+ models.CharField(blank=True, default="", max_length=100),
360
+ ),
361
+ ("history_id", models.AutoField(primary_key=True, serialize=False)),
362
+ ("history_date", models.DateTimeField(db_index=True)),
363
+ ("history_change_reason", models.CharField(max_length=100, null=True)),
364
+ (
365
+ "history_type",
366
+ models.CharField(
367
+ choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
368
+ max_length=1,
369
+ ),
370
+ ),
371
+ (
372
+ "created_by",
373
+ models.ForeignKey(
374
+ blank=True,
375
+ db_constraint=False,
376
+ null=True,
377
+ on_delete=django.db.models.deletion.DO_NOTHING,
378
+ related_name="+",
379
+ to=settings.AUTH_USER_MODEL,
380
+ ),
381
+ ),
382
+ (
383
+ "deleted_by",
384
+ models.ForeignKey(
385
+ blank=True,
386
+ db_constraint=False,
387
+ null=True,
388
+ on_delete=django.db.models.deletion.DO_NOTHING,
389
+ related_name="+",
390
+ to=settings.AUTH_USER_MODEL,
391
+ ),
392
+ ),
393
+ (
394
+ "history_user",
395
+ models.ForeignKey(
396
+ null=True,
397
+ on_delete=django.db.models.deletion.SET_NULL,
398
+ related_name="+",
399
+ to=settings.AUTH_USER_MODEL,
400
+ ),
401
+ ),
402
+ (
403
+ "updated_by",
404
+ models.ForeignKey(
405
+ blank=True,
406
+ db_constraint=False,
407
+ null=True,
408
+ on_delete=django.db.models.deletion.DO_NOTHING,
409
+ related_name="+",
410
+ to=settings.AUTH_USER_MODEL,
411
+ ),
412
+ ),
413
+ ],
414
+ options={
415
+ "verbose_name": "historical ssh key",
416
+ "verbose_name_plural": "historical ssh keys",
417
+ "ordering": ("-history_date", "-history_id"),
418
+ "get_latest_by": ("history_date", "history_id"),
419
+ },
420
+ bases=(simple_history.models.HistoricalChanges, models.Model),
421
+ ),
422
+ migrations.CreateModel(
423
+ name="SSHKey",
424
+ fields=[
425
+ (
426
+ "id",
427
+ models.BigAutoField(
428
+ auto_created=True,
429
+ primary_key=True,
430
+ serialize=False,
431
+ verbose_name="ID",
432
+ ),
433
+ ),
434
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
435
+ ("created_at", models.DateTimeField(auto_now_add=True)),
436
+ ("updated_at", models.DateTimeField(auto_now=True)),
437
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
438
+ ("name", models.CharField(max_length=200)),
439
+ ("public_key", models.TextField()),
440
+ (
441
+ "private_key_path",
442
+ models.CharField(help_text="Path to private key file on disk", max_length=500),
443
+ ),
444
+ (
445
+ "fingerprint",
446
+ models.CharField(blank=True, default="", max_length=100),
447
+ ),
448
+ (
449
+ "created_by",
450
+ models.ForeignKey(
451
+ blank=True,
452
+ null=True,
453
+ on_delete=django.db.models.deletion.SET_NULL,
454
+ related_name="+",
455
+ to=settings.AUTH_USER_MODEL,
456
+ ),
457
+ ),
458
+ (
459
+ "deleted_by",
460
+ models.ForeignKey(
461
+ blank=True,
462
+ null=True,
463
+ on_delete=django.db.models.deletion.SET_NULL,
464
+ related_name="+",
465
+ to=settings.AUTH_USER_MODEL,
466
+ ),
467
+ ),
468
+ (
469
+ "updated_by",
470
+ models.ForeignKey(
471
+ blank=True,
472
+ null=True,
473
+ on_delete=django.db.models.deletion.SET_NULL,
474
+ related_name="+",
475
+ to=settings.AUTH_USER_MODEL,
476
+ ),
477
+ ),
478
+ ],
479
+ options={
480
+ "ordering": ["-created_at"],
481
+ },
482
+ ),
483
+ migrations.CreateModel(
484
+ name="SyncLog",
485
+ fields=[
486
+ (
487
+ "id",
488
+ models.BigAutoField(
489
+ auto_created=True,
490
+ primary_key=True,
491
+ serialize=False,
492
+ verbose_name="ID",
493
+ ),
494
+ ),
495
+ ("started_at", models.DateTimeField(auto_now_add=True)),
496
+ ("completed_at", models.DateTimeField(blank=True, null=True)),
497
+ ("status", models.CharField(default="running", max_length=20)),
498
+ ("artifacts_synced", models.PositiveIntegerField(default=0)),
499
+ ("message", models.TextField(blank=True, default="")),
500
+ ("triggered_by", models.CharField(default="manual", max_length=20)),
501
+ (
502
+ "mirror",
503
+ models.ForeignKey(
504
+ on_delete=django.db.models.deletion.CASCADE,
505
+ related_name="logs",
506
+ to="fossil.gitmirror",
507
+ ),
508
+ ),
509
+ ],
510
+ options={
511
+ "ordering": ["-started_at"],
512
+ },
513
+ ),
514
+ ]
--- a/fossil/migrations/0003_gitmirror_historicalgitmirror_historicalsshkey_and_more.py
+++ b/fossil/migrations/0003_gitmirror_historicalgitmirror_historicalsshkey_and_more.py
@@ -0,0 +1,514 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil/migrations/0003_gitmirror_historicalgitmirror_historicalsshkey_and_more.py
+++ b/fossil/migrations/0003_gitmirror_historicalgitmirror_historicalsshkey_and_more.py
@@ -0,0 +1,514 @@
1 # Generated by Django 5.2.12 on 2026-04-07 02:51
2
3 import django.db.models.deletion
4 import simple_history.models
5 from django.conf import settings
6 from django.db import migrations, models
7
8
9 class Migration(migrations.Migration):
10 dependencies = [
11 ("fossil", "0002_fossilrepository_last_sync_at_and_more"),
12 migrations.swappable_dependency(settings.AUTH_USER_MODEL),
13 ]
14
15 operations = [
16 migrations.CreateModel(
17 name="GitMirror",
18 fields=[
19 (
20 "id",
21 models.BigAutoField(
22 auto_created=True,
23 primary_key=True,
24 serialize=False,
25 verbose_name="ID",
26 ),
27 ),
28 ("version", models.PositiveIntegerField(default=1, editable=False)),
29 ("created_at", models.DateTimeField(auto_now_add=True)),
30 ("updated_at", models.DateTimeField(auto_now=True)),
31 ("deleted_at", models.DateTimeField(blank=True, null=True)),
32 (
33 "git_remote_url",
34 models.CharField(help_text="Git remote URL (SSH or HTTPS)", max_length=500),
35 ),
36 (
37 "auth_method",
38 models.CharField(
39 choices=[
40 ("ssh", "SSH Key"),
41 ("token", "Personal Access Token"),
42 ("oauth_github", "GitHub OAuth"),
43 ("oauth_gitlab", "GitLab OAuth"),
44 ],
45 default="token",
46 max_length=20,
47 ),
48 ),
49 (
50 "auth_credential",
51 models.TextField(
52 blank=True,
53 default="",
54 help_text="Encrypted token or key reference",
55 ),
56 ),
57 (
58 "sync_direction",
59 models.CharField(
60 choices=[
61 ("push", "Push (Fossil → Git)"),
62 ("pull", "Pull (Git → Fossil)"),
63 ("both", "Bidirectional"),
64 ],
65 default="push",
66 max_length=10,
67 ),
68 ),
69 (
70 "sync_mode",
71 models.CharField(
72 choices=[
73 ("on_change", "On Change"),
74 ("scheduled", "Scheduled"),
75 ("both", "Both"),
76 ("disabled", "Disabled"),
77 ],
78 default="scheduled",
79 max_length=20,
80 ),
81 ),
82 (
83 "sync_schedule",
84 models.CharField(
85 blank=True,
86 default="*/15 * * * *",
87 help_text="Cron expression for scheduled sync",
88 max_length=100,
89 ),
90 ),
91 ("sync_code", models.BooleanField(default=True)),
92 (
93 "sync_tickets",
94 models.BooleanField(default=False, help_text="Sync tickets as GitHub/GitLab Issues"),
95 ),
96 (
97 "sync_wiki",
98 models.BooleanField(default=False, help_text="Sync wiki pages"),
99 ),
100 (
101 "fossil_branch",
102 models.CharField(
103 default="trunk",
104 help_text="Fossil branch to sync",
105 max_length=100,
106 ),
107 ),
108 (
109 "git_branch",
110 models.CharField(
111 default="main",
112 help_text="Git branch to push to",
113 max_length=100,
114 ),
115 ),
116 ("last_sync_at", models.DateTimeField(blank=True, null=True)),
117 (
118 "last_sync_status",
119 models.CharField(blank=True, default="", max_length=20),
120 ),
121 ("last_sync_message", models.TextField(blank=True, default="")),
122 ("total_syncs", models.PositiveIntegerField(default=0)),
123 (
124 "created_by",
125 models.ForeignKey(
126 blank=True,
127 null=True,
128 on_delete=django.db.models.deletion.SET_NULL,
129 related_name="+",
130 to=settings.AUTH_USER_MODEL,
131 ),
132 ),
133 (
134 "deleted_by",
135 models.ForeignKey(
136 blank=True,
137 null=True,
138 on_delete=django.db.models.deletion.SET_NULL,
139 related_name="+",
140 to=settings.AUTH_USER_MODEL,
141 ),
142 ),
143 (
144 "repository",
145 models.ForeignKey(
146 on_delete=django.db.models.deletion.CASCADE,
147 related_name="git_mirrors",
148 to="fossil.fossilrepository",
149 ),
150 ),
151 (
152 "updated_by",
153 models.ForeignKey(
154 blank=True,
155 null=True,
156 on_delete=django.db.models.deletion.SET_NULL,
157 related_name="+",
158 to=settings.AUTH_USER_MODEL,
159 ),
160 ),
161 ],
162 options={
163 "ordering": ["-created_at"],
164 },
165 ),
166 migrations.CreateModel(
167 name="HistoricalGitMirror",
168 fields=[
169 (
170 "id",
171 models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
172 ),
173 ("version", models.PositiveIntegerField(default=1, editable=False)),
174 ("created_at", models.DateTimeField(blank=True, editable=False)),
175 ("updated_at", models.DateTimeField(blank=True, editable=False)),
176 ("deleted_at", models.DateTimeField(blank=True, null=True)),
177 (
178 "git_remote_url",
179 models.CharField(help_text="Git remote URL (SSH or HTTPS)", max_length=500),
180 ),
181 (
182 "auth_method",
183 models.CharField(
184 choices=[
185 ("ssh", "SSH Key"),
186 ("token", "Personal Access Token"),
187 ("oauth_github", "GitHub OAuth"),
188 ("oauth_gitlab", "GitLab OAuth"),
189 ],
190 default="token",
191 max_length=20,
192 ),
193 ),
194 (
195 "auth_credential",
196 models.TextField(
197 blank=True,
198 default="",
199 help_text="Encrypted token or key reference",
200 ),
201 ),
202 (
203 "sync_direction",
204 models.CharField(
205 choices=[
206 ("push", "Push (Fossil → Git)"),
207 ("pull", "Pull (Git → Fossil)"),
208 ("both", "Bidirectional"),
209 ],
210 default="push",
211 max_length=10,
212 ),
213 ),
214 (
215 "sync_mode",
216 models.CharField(
217 choices=[
218 ("on_change", "On Change"),
219 ("scheduled", "Scheduled"),
220 ("both", "Both"),
221 ("disabled", "Disabled"),
222 ],
223 default="scheduled",
224 max_length=20,
225 ),
226 ),
227 (
228 "sync_schedule",
229 models.CharField(
230 blank=True,
231 default="*/15 * * * *",
232 help_text="Cron expression for scheduled sync",
233 max_length=100,
234 ),
235 ),
236 ("sync_code", models.BooleanField(default=True)),
237 (
238 "sync_tickets",
239 models.BooleanField(default=False, help_text="Sync tickets as GitHub/GitLab Issues"),
240 ),
241 (
242 "sync_wiki",
243 models.BooleanField(default=False, help_text="Sync wiki pages"),
244 ),
245 (
246 "fossil_branch",
247 models.CharField(
248 default="trunk",
249 help_text="Fossil branch to sync",
250 max_length=100,
251 ),
252 ),
253 (
254 "git_branch",
255 models.CharField(
256 default="main",
257 help_text="Git branch to push to",
258 max_length=100,
259 ),
260 ),
261 ("last_sync_at", models.DateTimeField(blank=True, null=True)),
262 (
263 "last_sync_status",
264 models.CharField(blank=True, default="", max_length=20),
265 ),
266 ("last_sync_message", models.TextField(blank=True, default="")),
267 ("total_syncs", models.PositiveIntegerField(default=0)),
268 ("history_id", models.AutoField(primary_key=True, serialize=False)),
269 ("history_date", models.DateTimeField(db_index=True)),
270 ("history_change_reason", models.CharField(max_length=100, null=True)),
271 (
272 "history_type",
273 models.CharField(
274 choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
275 max_length=1,
276 ),
277 ),
278 (
279 "created_by",
280 models.ForeignKey(
281 blank=True,
282 db_constraint=False,
283 null=True,
284 on_delete=django.db.models.deletion.DO_NOTHING,
285 related_name="+",
286 to=settings.AUTH_USER_MODEL,
287 ),
288 ),
289 (
290 "deleted_by",
291 models.ForeignKey(
292 blank=True,
293 db_constraint=False,
294 null=True,
295 on_delete=django.db.models.deletion.DO_NOTHING,
296 related_name="+",
297 to=settings.AUTH_USER_MODEL,
298 ),
299 ),
300 (
301 "history_user",
302 models.ForeignKey(
303 null=True,
304 on_delete=django.db.models.deletion.SET_NULL,
305 related_name="+",
306 to=settings.AUTH_USER_MODEL,
307 ),
308 ),
309 (
310 "repository",
311 models.ForeignKey(
312 blank=True,
313 db_constraint=False,
314 null=True,
315 on_delete=django.db.models.deletion.DO_NOTHING,
316 related_name="+",
317 to="fossil.fossilrepository",
318 ),
319 ),
320 (
321 "updated_by",
322 models.ForeignKey(
323 blank=True,
324 db_constraint=False,
325 null=True,
326 on_delete=django.db.models.deletion.DO_NOTHING,
327 related_name="+",
328 to=settings.AUTH_USER_MODEL,
329 ),
330 ),
331 ],
332 options={
333 "verbose_name": "historical git mirror",
334 "verbose_name_plural": "historical git mirrors",
335 "ordering": ("-history_date", "-history_id"),
336 "get_latest_by": ("history_date", "history_id"),
337 },
338 bases=(simple_history.models.HistoricalChanges, models.Model),
339 ),
340 migrations.CreateModel(
341 name="HistoricalSSHKey",
342 fields=[
343 (
344 "id",
345 models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
346 ),
347 ("version", models.PositiveIntegerField(default=1, editable=False)),
348 ("created_at", models.DateTimeField(blank=True, editable=False)),
349 ("updated_at", models.DateTimeField(blank=True, editable=False)),
350 ("deleted_at", models.DateTimeField(blank=True, null=True)),
351 ("name", models.CharField(max_length=200)),
352 ("public_key", models.TextField()),
353 (
354 "private_key_path",
355 models.CharField(help_text="Path to private key file on disk", max_length=500),
356 ),
357 (
358 "fingerprint",
359 models.CharField(blank=True, default="", max_length=100),
360 ),
361 ("history_id", models.AutoField(primary_key=True, serialize=False)),
362 ("history_date", models.DateTimeField(db_index=True)),
363 ("history_change_reason", models.CharField(max_length=100, null=True)),
364 (
365 "history_type",
366 models.CharField(
367 choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
368 max_length=1,
369 ),
370 ),
371 (
372 "created_by",
373 models.ForeignKey(
374 blank=True,
375 db_constraint=False,
376 null=True,
377 on_delete=django.db.models.deletion.DO_NOTHING,
378 related_name="+",
379 to=settings.AUTH_USER_MODEL,
380 ),
381 ),
382 (
383 "deleted_by",
384 models.ForeignKey(
385 blank=True,
386 db_constraint=False,
387 null=True,
388 on_delete=django.db.models.deletion.DO_NOTHING,
389 related_name="+",
390 to=settings.AUTH_USER_MODEL,
391 ),
392 ),
393 (
394 "history_user",
395 models.ForeignKey(
396 null=True,
397 on_delete=django.db.models.deletion.SET_NULL,
398 related_name="+",
399 to=settings.AUTH_USER_MODEL,
400 ),
401 ),
402 (
403 "updated_by",
404 models.ForeignKey(
405 blank=True,
406 db_constraint=False,
407 null=True,
408 on_delete=django.db.models.deletion.DO_NOTHING,
409 related_name="+",
410 to=settings.AUTH_USER_MODEL,
411 ),
412 ),
413 ],
414 options={
415 "verbose_name": "historical ssh key",
416 "verbose_name_plural": "historical ssh keys",
417 "ordering": ("-history_date", "-history_id"),
418 "get_latest_by": ("history_date", "history_id"),
419 },
420 bases=(simple_history.models.HistoricalChanges, models.Model),
421 ),
422 migrations.CreateModel(
423 name="SSHKey",
424 fields=[
425 (
426 "id",
427 models.BigAutoField(
428 auto_created=True,
429 primary_key=True,
430 serialize=False,
431 verbose_name="ID",
432 ),
433 ),
434 ("version", models.PositiveIntegerField(default=1, editable=False)),
435 ("created_at", models.DateTimeField(auto_now_add=True)),
436 ("updated_at", models.DateTimeField(auto_now=True)),
437 ("deleted_at", models.DateTimeField(blank=True, null=True)),
438 ("name", models.CharField(max_length=200)),
439 ("public_key", models.TextField()),
440 (
441 "private_key_path",
442 models.CharField(help_text="Path to private key file on disk", max_length=500),
443 ),
444 (
445 "fingerprint",
446 models.CharField(blank=True, default="", max_length=100),
447 ),
448 (
449 "created_by",
450 models.ForeignKey(
451 blank=True,
452 null=True,
453 on_delete=django.db.models.deletion.SET_NULL,
454 related_name="+",
455 to=settings.AUTH_USER_MODEL,
456 ),
457 ),
458 (
459 "deleted_by",
460 models.ForeignKey(
461 blank=True,
462 null=True,
463 on_delete=django.db.models.deletion.SET_NULL,
464 related_name="+",
465 to=settings.AUTH_USER_MODEL,
466 ),
467 ),
468 (
469 "updated_by",
470 models.ForeignKey(
471 blank=True,
472 null=True,
473 on_delete=django.db.models.deletion.SET_NULL,
474 related_name="+",
475 to=settings.AUTH_USER_MODEL,
476 ),
477 ),
478 ],
479 options={
480 "ordering": ["-created_at"],
481 },
482 ),
483 migrations.CreateModel(
484 name="SyncLog",
485 fields=[
486 (
487 "id",
488 models.BigAutoField(
489 auto_created=True,
490 primary_key=True,
491 serialize=False,
492 verbose_name="ID",
493 ),
494 ),
495 ("started_at", models.DateTimeField(auto_now_add=True)),
496 ("completed_at", models.DateTimeField(blank=True, null=True)),
497 ("status", models.CharField(default="running", max_length=20)),
498 ("artifacts_synced", models.PositiveIntegerField(default=0)),
499 ("message", models.TextField(blank=True, default="")),
500 ("triggered_by", models.CharField(default="manual", max_length=20)),
501 (
502 "mirror",
503 models.ForeignKey(
504 on_delete=django.db.models.deletion.CASCADE,
505 related_name="logs",
506 to="fossil.gitmirror",
507 ),
508 ),
509 ],
510 options={
511 "ordering": ["-started_at"],
512 },
513 ),
514 ]
--- fossil/models.py
+++ fossil/models.py
@@ -62,5 +62,9 @@
6262
ordering = ["-created_at"]
6363
get_latest_by = "created_at"
6464
6565
def __str__(self):
6666
return f"{self.repository.filename} @ {self.created_at:%Y-%m-%d %H:%M}" if self.created_at else self.repository.filename
67
+
68
+
69
+# Import sync models so they're discoverable by Django
70
+from fossil.sync_models import GitMirror, SSHKey, SyncLog # noqa: E402, F401
6771
6872
ADDED fossil/sync_models.py
--- fossil/models.py
+++ fossil/models.py
@@ -62,5 +62,9 @@
62 ordering = ["-created_at"]
63 get_latest_by = "created_at"
64
65 def __str__(self):
66 return f"{self.repository.filename} @ {self.created_at:%Y-%m-%d %H:%M}" if self.created_at else self.repository.filename
 
 
 
 
67
68 DDED fossil/sync_models.py
--- fossil/models.py
+++ fossil/models.py
@@ -62,5 +62,9 @@
62 ordering = ["-created_at"]
63 get_latest_by = "created_at"
64
65 def __str__(self):
66 return f"{self.repository.filename} @ {self.created_at:%Y-%m-%d %H:%M}" if self.created_at else self.repository.filename
67
68
69 # Import sync models so they're discoverable by Django
70 from fossil.sync_models import GitMirror, SSHKey, SyncLog # noqa: E402, F401
71
72 DDED fossil/sync_models.py
--- a/fossil/sync_models.py
+++ b/fossil/sync_models.py
@@ -0,0 +1,42 @@
1
+"""Git mirror sync models for Fossil-to-Git synchronization."""
2
+
3
+from django.dmodels import ActiveManager, Tracking
4
+
5
+
6
+class GitMirror(Tracking):
7
+ """Configuration for syncing a Fossil repo to a Git remote."""
8
+
9
+ class AuthMethod(models.TextChoices):
10
+ SSH_KEY = "ssh", "SSH Key"
11
+ TOKEN = "token", "Personal Access Token"
12
+ OAUTH_GITHUB = "oauth_github", "GitHub OAuth"
13
+ OAUTH_GITLAB = "oauth_gitlab", "GitLab OAuth"
14
+
15
+ class SyncDirection(models.TextChoices):
16
+ PUSH = "push", "Push (Fossil → Git)"
17
+ PULL = "pull", "Pull (Git → Fossil)"
18
+ BOTH = "both", "Bidirectional"
19
+
20
+ class SyncMode(models.TextChoices):
21
+ ON_CHANGE = "on_change", "On Change"
22
+ SCHEDULED = "scheduled", "Scheduled"
23
+ BOTH = "both", "Both"
24
+ DISABLED = "disabled", "Disabled"
25
+
26
+ repository = models.ForeignKey("fossil.FossilRepository", on_delete=models.CASCADE, related_name="git_mirrors")
27
+ git_remote_url = models.CharField(max_length=500, help_text="Git remote URL (SSH or HTTPS)")
28
+ auth_method = models.CharField(max_length=20, choices=AuthMethod.choices, default=AuthMethod.TOKEN)
29
+ auth_credential = models.TextField(blank=TrEncrypted token or key referenceference (encrypted at rest)")
30
+
31
+ sync_direction = models.CharField(max_length=10, choices=SyncDirection.choices, default=SyncDirection.PUSH)
32
+ sync_mode = models.CharField(max_length=20, choices=SyncMode.choices, default=SyncMode.SCHEDULED)
33
+ sync_schedule = models.CharField(max_length=100, blank=True, default="*/15 * * * *", help_text="Cron expression for scheduled sync")
34
+
35
+ # What to sync
36
+ sync_code = models.BooleanField(default=True)
37
+ sync_tickets = models.BooleanField(default=False, help_text="Sync tickets as GitHub/GitLab Issues")
38
+ sync_wiki = models.BooleanField(default=False, help_text="Sync wiki pages")
39
+
40
+ # Branch mapping
41
+ fossil_branch = models.CharField(max_length=100, default="trunk", help_text="Fossil branch to sync")
42
+ git_branch = models.CharField(max_length=100, default="main", help_text="Gi
--- a/fossil/sync_models.py
+++ b/fossil/sync_models.py
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil/sync_models.py
+++ b/fossil/sync_models.py
@@ -0,0 +1,42 @@
1 """Git mirror sync models for Fossil-to-Git synchronization."""
2
3 from django.dmodels import ActiveManager, Tracking
4
5
6 class GitMirror(Tracking):
7 """Configuration for syncing a Fossil repo to a Git remote."""
8
9 class AuthMethod(models.TextChoices):
10 SSH_KEY = "ssh", "SSH Key"
11 TOKEN = "token", "Personal Access Token"
12 OAUTH_GITHUB = "oauth_github", "GitHub OAuth"
13 OAUTH_GITLAB = "oauth_gitlab", "GitLab OAuth"
14
15 class SyncDirection(models.TextChoices):
16 PUSH = "push", "Push (Fossil → Git)"
17 PULL = "pull", "Pull (Git → Fossil)"
18 BOTH = "both", "Bidirectional"
19
20 class SyncMode(models.TextChoices):
21 ON_CHANGE = "on_change", "On Change"
22 SCHEDULED = "scheduled", "Scheduled"
23 BOTH = "both", "Both"
24 DISABLED = "disabled", "Disabled"
25
26 repository = models.ForeignKey("fossil.FossilRepository", on_delete=models.CASCADE, related_name="git_mirrors")
27 git_remote_url = models.CharField(max_length=500, help_text="Git remote URL (SSH or HTTPS)")
28 auth_method = models.CharField(max_length=20, choices=AuthMethod.choices, default=AuthMethod.TOKEN)
29 auth_credential = models.TextField(blank=TrEncrypted token or key referenceference (encrypted at rest)")
30
31 sync_direction = models.CharField(max_length=10, choices=SyncDirection.choices, default=SyncDirection.PUSH)
32 sync_mode = models.CharField(max_length=20, choices=SyncMode.choices, default=SyncMode.SCHEDULED)
33 sync_schedule = models.CharField(max_length=100, blank=True, default="*/15 * * * *", help_text="Cron expression for scheduled sync")
34
35 # What to sync
36 sync_code = models.BooleanField(default=True)
37 sync_tickets = models.BooleanField(default=False, help_text="Sync tickets as GitHub/GitLab Issues")
38 sync_wiki = models.BooleanField(default=False, help_text="Sync wiki pages")
39
40 # Branch mapping
41 fossil_branch = models.CharField(max_length=100, default="trunk", help_text="Fossil branch to sync")
42 git_branch = models.CharField(max_length=100, default="main", help_text="Gi
--- fossil/tasks.py
+++ fossil/tasks.py
@@ -113,5 +113,80 @@
113113
repo.upstream_artifacts_available = 0
114114
repo.last_sync_at = timezone.now()
115115
repo.save(update_fields=["upstream_artifacts_available", "last_sync_at", "updated_at", "version"])
116116
except Exception:
117117
logger.exception("Failed to check upstream for %s", repo.filename)
118
+
119
+
120
+@shared_task(name="fossil.git_sync")
121
+def run_git_sync(mirror_id: int | None = None):
122
+ """Run Git export/push for configured mirrors."""
123
+ from pathlib import Path
124
+
125
+ from constance import config
126
+ from django.utils import timezone
127
+
128
+ from fossil.cli import FossilCLI
129
+ from fossil.sync_models import GitMirror, SyncLog
130
+
131
+ cli = FossilCLI()
132
+ if not cli.is_available():
133
+ return
134
+
135
+ mirrors = GitMirror.objects.filter(deleted_at__isnull=True).exclude(sync_mode="disabled")
136
+ if mirror_id:
137
+ mirrors = mirrors.filter(pk=mirror_id)
138
+
139
+ mirror_dir = Path(config.GIT_MIRROR_DIR)
140
+
141
+ for mirror in mirrors:
142
+ repo = mirror.repository
143
+ if not repo.exists_on_disk:
144
+ continue
145
+
146
+ log = SyncLog.objects.create(mirror=mirror, triggered_by="schedule" if not mirror_id else "manual")
147
+
148
+ try:
149
+ # Ensure default user
150
+ cli.ensure_default_user(repo.full_path)
151
+
152
+ # Git export directory for this mirror
153
+ export_dir = mirror_dir / f"{repo.filename.replace('.fossil', '')}-git"
154
+
155
+ # Build autopush URL with credentials if token auth
156
+ push_url = mirror.git_remote_url
157
+ if mirror.auth_method == "token" and mirror.auth_credential and push_url.startswith("https://"):
158
+ push_url = push_url.replace("https://", f"https://{mirror.auth_credential}@")
159
+
160
+ result = cli.git_export(repo.full_path, export_dir, autopush_url=push_url)
161
+
162
+ log.status = "success" if result["success"] else "failed"
163
+ log.message = result["message"]
164
+ log.completed_at = timezone.now()
165
+ log.save()
166
+
167
+ mirror.last_sync_at = timezone.now()
168
+ mirror.last_sync_status = log.status
169
+ mirror.last_sync_message = result["message"][:500]
170
+ mirror.total_syncs += 1
171
+ mirror.save(
172
+ update_fields=[
173
+ "last_sync_at",
174
+ "last_sync_status",
175
+ "last_sync_message",
176
+ "total_syncs",
177
+ "updated_at",
178
+ "version",
179
+ ]
180
+ )
181
+
182
+ if result["success"]:
183
+ logger.info("Git sync success for %s → %s", repo.filename, mirror.git_remote_url)
184
+ else:
185
+ logger.warning("Git sync failed for %s: %s", repo.filename, result["message"][:200])
186
+
187
+ except Exception:
188
+ logger.exception("Git sync error for %s", repo.filename)
189
+ log.status = "failed"
190
+ log.message = "Unexpected error"
191
+ log.completed_at = timezone.now()
192
+ log.save()
118193
--- fossil/tasks.py
+++ fossil/tasks.py
@@ -113,5 +113,80 @@
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/tasks.py
+++ fossil/tasks.py
@@ -113,5 +113,80 @@
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
119
120 @shared_task(name="fossil.git_sync")
121 def run_git_sync(mirror_id: int | None = None):
122 """Run Git export/push for configured mirrors."""
123 from pathlib import Path
124
125 from constance import config
126 from django.utils import timezone
127
128 from fossil.cli import FossilCLI
129 from fossil.sync_models import GitMirror, SyncLog
130
131 cli = FossilCLI()
132 if not cli.is_available():
133 return
134
135 mirrors = GitMirror.objects.filter(deleted_at__isnull=True).exclude(sync_mode="disabled")
136 if mirror_id:
137 mirrors = mirrors.filter(pk=mirror_id)
138
139 mirror_dir = Path(config.GIT_MIRROR_DIR)
140
141 for mirror in mirrors:
142 repo = mirror.repository
143 if not repo.exists_on_disk:
144 continue
145
146 log = SyncLog.objects.create(mirror=mirror, triggered_by="schedule" if not mirror_id else "manual")
147
148 try:
149 # Ensure default user
150 cli.ensure_default_user(repo.full_path)
151
152 # Git export directory for this mirror
153 export_dir = mirror_dir / f"{repo.filename.replace('.fossil', '')}-git"
154
155 # Build autopush URL with credentials if token auth
156 push_url = mirror.git_remote_url
157 if mirror.auth_method == "token" and mirror.auth_credential and push_url.startswith("https://"):
158 push_url = push_url.replace("https://", f"https://{mirror.auth_credential}@")
159
160 result = cli.git_export(repo.full_path, export_dir, autopush_url=push_url)
161
162 log.status = "success" if result["success"] else "failed"
163 log.message = result["message"]
164 log.completed_at = timezone.now()
165 log.save()
166
167 mirror.last_sync_at = timezone.now()
168 mirror.last_sync_status = log.status
169 mirror.last_sync_message = result["message"][:500]
170 mirror.total_syncs += 1
171 mirror.save(
172 update_fields=[
173 "last_sync_at",
174 "last_sync_status",
175 "last_sync_message",
176 "total_syncs",
177 "updated_at",
178 "version",
179 ]
180 )
181
182 if result["success"]:
183 logger.info("Git sync success for %s → %s", repo.filename, mirror.git_remote_url)
184 else:
185 logger.warning("Git sync failed for %s: %s", repo.filename, result["message"][:200])
186
187 except Exception:
188 logger.exception("Git sync error for %s", repo.filename)
189 log.status = "failed"
190 log.message = "Unexpected error"
191 log.completed_at = timezone.now()
192 log.save()
193

Keyboard Shortcuts

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