@serve.zone/gitops 2.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (258) hide show
  1. package/.smartconfig.json +114 -0
  2. package/binary/gitops.ts +4 -0
  3. package/changelog.md +185 -0
  4. package/cli.child.js +4 -0
  5. package/cli.js +4 -0
  6. package/cli.ts.js +5 -0
  7. package/deno.json +10 -0
  8. package/dist_serve/bundle.js +36362 -0
  9. package/dist_serve/index.html +33 -0
  10. package/dist_ts/00_commitinfo_data.d.ts +8 -0
  11. package/dist_ts/00_commitinfo_data.js +9 -0
  12. package/dist_ts/cache/classes.cache.cleaner.d.ts +23 -0
  13. package/dist_ts/cache/classes.cache.cleaner.js +61 -0
  14. package/dist_ts/cache/classes.cached.document.d.ts +30 -0
  15. package/dist_ts/cache/classes.cached.document.js +101 -0
  16. package/dist_ts/cache/classes.cachedb.d.ts +22 -0
  17. package/dist_ts/cache/classes.cachedb.js +58 -0
  18. package/dist_ts/cache/classes.secrets.scan.service.d.ts +51 -0
  19. package/dist_ts/cache/classes.secrets.scan.service.js +237 -0
  20. package/dist_ts/cache/documents/classes.cached.project.d.ts +13 -0
  21. package/dist_ts/cache/documents/classes.cached.project.js +101 -0
  22. package/dist_ts/cache/documents/classes.cached.secret.d.ts +24 -0
  23. package/dist_ts/cache/documents/classes.cached.secret.js +158 -0
  24. package/dist_ts/cache/documents/index.d.ts +2 -0
  25. package/dist_ts/cache/documents/index.js +3 -0
  26. package/dist_ts/cache/index.d.ts +7 -0
  27. package/dist_ts/cache/index.js +6 -0
  28. package/dist_ts/classes/actionlog.d.ts +19 -0
  29. package/dist_ts/classes/actionlog.js +44 -0
  30. package/dist_ts/classes/connectionmanager.d.ts +57 -0
  31. package/dist_ts/classes/connectionmanager.js +247 -0
  32. package/dist_ts/classes/gitopsapp.d.ts +30 -0
  33. package/dist_ts/classes/gitopsapp.js +101 -0
  34. package/dist_ts/classes/jobmanager.d.ts +47 -0
  35. package/dist_ts/classes/jobmanager.js +301 -0
  36. package/dist_ts/classes/jobrunners/autobookstackdocs.runner.d.ts +29 -0
  37. package/dist_ts/classes/jobrunners/autobookstackdocs.runner.js +361 -0
  38. package/dist_ts/classes/jobrunners/base.jobrunner.d.ts +14 -0
  39. package/dist_ts/classes/jobrunners/base.jobrunner.js +3 -0
  40. package/dist_ts/classes/jobrunners/index.d.ts +5 -0
  41. package/dist_ts/classes/jobrunners/index.js +14 -0
  42. package/dist_ts/classes/managedsecrets.manager.d.ts +47 -0
  43. package/dist_ts/classes/managedsecrets.manager.js +247 -0
  44. package/dist_ts/classes/syncmanager.d.ts +189 -0
  45. package/dist_ts/classes/syncmanager.js +1787 -0
  46. package/dist_ts/index.d.ts +6 -0
  47. package/dist_ts/index.js +32 -0
  48. package/dist_ts/logging.d.ts +49 -0
  49. package/dist_ts/logging.js +134 -0
  50. package/dist_ts/opsserver/classes.opsserver.d.ts +25 -0
  51. package/dist_ts/opsserver/classes.opsserver.js +70 -0
  52. package/dist_ts/opsserver/handlers/actionlog.handler.d.ts +9 -0
  53. package/dist_ts/opsserver/handlers/actionlog.handler.js +24 -0
  54. package/dist_ts/opsserver/handlers/actions.handler.d.ts +9 -0
  55. package/dist_ts/opsserver/handlers/actions.handler.js +38 -0
  56. package/dist_ts/opsserver/handlers/admin.handler.d.ts +19 -0
  57. package/dist_ts/opsserver/handlers/admin.handler.js +96 -0
  58. package/dist_ts/opsserver/handlers/connections.handler.d.ts +10 -0
  59. package/dist_ts/opsserver/handlers/connections.handler.js +109 -0
  60. package/dist_ts/opsserver/handlers/groups.handler.d.ts +9 -0
  61. package/dist_ts/opsserver/handlers/groups.handler.js +24 -0
  62. package/dist_ts/opsserver/handlers/index.d.ts +13 -0
  63. package/dist_ts/opsserver/handlers/index.js +14 -0
  64. package/dist_ts/opsserver/handlers/jobs.handler.d.ts +16 -0
  65. package/dist_ts/opsserver/handlers/jobs.handler.js +146 -0
  66. package/dist_ts/opsserver/handlers/logs.handler.d.ts +9 -0
  67. package/dist_ts/opsserver/handlers/logs.handler.js +21 -0
  68. package/dist_ts/opsserver/handlers/managedsecrets.handler.d.ts +11 -0
  69. package/dist_ts/opsserver/handlers/managedsecrets.handler.js +110 -0
  70. package/dist_ts/opsserver/handlers/pipelines.handler.d.ts +31 -0
  71. package/dist_ts/opsserver/handlers/pipelines.handler.js +204 -0
  72. package/dist_ts/opsserver/handlers/projects.handler.d.ts +9 -0
  73. package/dist_ts/opsserver/handlers/projects.handler.js +24 -0
  74. package/dist_ts/opsserver/handlers/secrets.handler.d.ts +10 -0
  75. package/dist_ts/opsserver/handlers/secrets.handler.js +171 -0
  76. package/dist_ts/opsserver/handlers/sync.handler.d.ts +16 -0
  77. package/dist_ts/opsserver/handlers/sync.handler.js +166 -0
  78. package/dist_ts/opsserver/handlers/webhook.handler.d.ts +7 -0
  79. package/dist_ts/opsserver/handlers/webhook.handler.js +55 -0
  80. package/dist_ts/opsserver/helpers/guards.d.ts +5 -0
  81. package/dist_ts/opsserver/helpers/guards.js +12 -0
  82. package/dist_ts/opsserver/index.d.ts +1 -0
  83. package/dist_ts/opsserver/index.js +2 -0
  84. package/dist_ts/paths.d.ts +9 -0
  85. package/dist_ts/paths.js +13 -0
  86. package/dist_ts/plugins.d.ts +25 -0
  87. package/dist_ts/plugins.js +32 -0
  88. package/dist_ts/providers/classes.baseprovider.d.ts +51 -0
  89. package/dist_ts/providers/classes.baseprovider.js +17 -0
  90. package/dist_ts/providers/classes.giteaprovider.d.ts +40 -0
  91. package/dist_ts/providers/classes.giteaprovider.js +224 -0
  92. package/dist_ts/providers/classes.gitlabprovider.d.ts +39 -0
  93. package/dist_ts/providers/classes.gitlabprovider.js +207 -0
  94. package/dist_ts/providers/index.d.ts +3 -0
  95. package/dist_ts/providers/index.js +4 -0
  96. package/dist_ts/storage/classes.storagemanager.d.ts +33 -0
  97. package/dist_ts/storage/classes.storagemanager.js +135 -0
  98. package/dist_ts/storage/index.d.ts +2 -0
  99. package/dist_ts/storage/index.js +2 -0
  100. package/dist_ts/timers.d.ts +4 -0
  101. package/dist_ts/timers.js +24 -0
  102. package/dist_ts_bundled/bundle.d.ts +4 -0
  103. package/dist_ts_bundled/bundle.js +12 -0
  104. package/dist_ts_interfaces/data/actionlog.d.ts +12 -0
  105. package/dist_ts_interfaces/data/actionlog.js +2 -0
  106. package/dist_ts_interfaces/data/branch.d.ts +8 -0
  107. package/dist_ts_interfaces/data/branch.js +2 -0
  108. package/dist_ts_interfaces/data/connection.d.ts +12 -0
  109. package/dist_ts_interfaces/data/connection.js +2 -0
  110. package/dist_ts_interfaces/data/group.d.ts +10 -0
  111. package/dist_ts_interfaces/data/group.js +2 -0
  112. package/dist_ts_interfaces/data/identity.d.ts +7 -0
  113. package/dist_ts_interfaces/data/identity.js +2 -0
  114. package/dist_ts_interfaces/data/index.d.ts +11 -0
  115. package/dist_ts_interfaces/data/index.js +12 -0
  116. package/dist_ts_interfaces/data/job.d.ts +37 -0
  117. package/dist_ts_interfaces/data/job.js +2 -0
  118. package/dist_ts_interfaces/data/managedsecret.d.ts +37 -0
  119. package/dist_ts_interfaces/data/managedsecret.js +2 -0
  120. package/dist_ts_interfaces/data/pipeline.d.ts +22 -0
  121. package/dist_ts_interfaces/data/pipeline.js +2 -0
  122. package/dist_ts_interfaces/data/project.d.ts +12 -0
  123. package/dist_ts_interfaces/data/project.js +2 -0
  124. package/dist_ts_interfaces/data/secret.d.ts +11 -0
  125. package/dist_ts_interfaces/data/secret.js +2 -0
  126. package/dist_ts_interfaces/data/sync.d.ts +34 -0
  127. package/dist_ts_interfaces/data/sync.js +2 -0
  128. package/dist_ts_interfaces/index.d.ts +5 -0
  129. package/dist_ts_interfaces/index.js +8 -0
  130. package/dist_ts_interfaces/plugins.d.ts +2 -0
  131. package/dist_ts_interfaces/plugins.js +4 -0
  132. package/dist_ts_interfaces/requests/actionlog.d.ts +15 -0
  133. package/dist_ts_interfaces/requests/actionlog.js +3 -0
  134. package/dist_ts_interfaces/requests/actions.d.ts +31 -0
  135. package/dist_ts_interfaces/requests/actions.js +3 -0
  136. package/dist_ts_interfaces/requests/admin.d.ts +31 -0
  137. package/dist_ts_interfaces/requests/admin.js +3 -0
  138. package/dist_ts_interfaces/requests/connections.d.ts +71 -0
  139. package/dist_ts_interfaces/requests/connections.js +3 -0
  140. package/dist_ts_interfaces/requests/groups.d.ts +14 -0
  141. package/dist_ts_interfaces/requests/groups.js +3 -0
  142. package/dist_ts_interfaces/requests/index.d.ts +13 -0
  143. package/dist_ts_interfaces/requests/index.js +14 -0
  144. package/dist_ts_interfaces/requests/jobs.d.ts +86 -0
  145. package/dist_ts_interfaces/requests/jobs.js +3 -0
  146. package/dist_ts_interfaces/requests/logs.d.ts +14 -0
  147. package/dist_ts_interfaces/requests/logs.js +3 -0
  148. package/dist_ts_interfaces/requests/managedsecrets.d.ts +84 -0
  149. package/dist_ts_interfaces/requests/managedsecrets.js +3 -0
  150. package/dist_ts_interfaces/requests/pipelines.d.ts +55 -0
  151. package/dist_ts_interfaces/requests/pipelines.js +3 -0
  152. package/dist_ts_interfaces/requests/projects.d.ts +14 -0
  153. package/dist_ts_interfaces/requests/projects.js +3 -0
  154. package/dist_ts_interfaces/requests/secrets.d.ts +72 -0
  155. package/dist_ts_interfaces/requests/secrets.js +3 -0
  156. package/dist_ts_interfaces/requests/sync.d.ts +120 -0
  157. package/dist_ts_interfaces/requests/sync.js +3 -0
  158. package/dist_ts_interfaces/requests/webhook.d.ts +13 -0
  159. package/dist_ts_interfaces/requests/webhook.js +3 -0
  160. package/license +21 -0
  161. package/package.json +81 -0
  162. package/readme.md +177 -0
  163. package/readme.todo.md +3 -0
  164. package/ts/00_commitinfo_data.ts +8 -0
  165. package/ts/cache/classes.cache.cleaner.ts +69 -0
  166. package/ts/cache/classes.cached.document.ts +57 -0
  167. package/ts/cache/classes.cachedb.ts +72 -0
  168. package/ts/cache/classes.secrets.scan.service.ts +267 -0
  169. package/ts/cache/documents/classes.cached.project.ts +32 -0
  170. package/ts/cache/documents/classes.cached.secret.ts +81 -0
  171. package/ts/cache/documents/index.ts +2 -0
  172. package/ts/cache/index.ts +7 -0
  173. package/ts/classes/actionlog.ts +57 -0
  174. package/ts/classes/connectionmanager.ts +263 -0
  175. package/ts/classes/gitopsapp.ts +128 -0
  176. package/ts/classes/jobmanager.ts +337 -0
  177. package/ts/classes/jobrunners/autobookstackdocs.runner.ts +435 -0
  178. package/ts/classes/jobrunners/base.jobrunner.ts +16 -0
  179. package/ts/classes/jobrunners/index.ts +17 -0
  180. package/ts/classes/managedsecrets.manager.ts +322 -0
  181. package/ts/classes/syncmanager.ts +2117 -0
  182. package/ts/index.ts +37 -0
  183. package/ts/logging.ts +162 -0
  184. package/ts/opsserver/classes.opsserver.ts +86 -0
  185. package/ts/opsserver/handlers/actionlog.handler.ts +30 -0
  186. package/ts/opsserver/handlers/actions.handler.ts +50 -0
  187. package/ts/opsserver/handlers/admin.handler.ts +122 -0
  188. package/ts/opsserver/handlers/connections.handler.ts +162 -0
  189. package/ts/opsserver/handlers/groups.handler.ts +32 -0
  190. package/ts/opsserver/handlers/index.ts +13 -0
  191. package/ts/opsserver/handlers/jobs.handler.ts +189 -0
  192. package/ts/opsserver/handlers/logs.handler.ts +29 -0
  193. package/ts/opsserver/handlers/managedsecrets.handler.ts +158 -0
  194. package/ts/opsserver/handlers/pipelines.handler.ts +281 -0
  195. package/ts/opsserver/handlers/projects.handler.ts +32 -0
  196. package/ts/opsserver/handlers/secrets.handler.ts +224 -0
  197. package/ts/opsserver/handlers/sync.handler.ts +224 -0
  198. package/ts/opsserver/handlers/webhook.handler.ts +62 -0
  199. package/ts/opsserver/helpers/guards.ts +16 -0
  200. package/ts/opsserver/index.ts +1 -0
  201. package/ts/paths.ts +19 -0
  202. package/ts/plugins.ts +38 -0
  203. package/ts/providers/classes.baseprovider.ts +99 -0
  204. package/ts/providers/classes.giteaprovider.ts +279 -0
  205. package/ts/providers/classes.gitlabprovider.ts +265 -0
  206. package/ts/providers/index.ts +3 -0
  207. package/ts/storage/classes.storagemanager.ts +144 -0
  208. package/ts/storage/index.ts +2 -0
  209. package/ts/timers.ts +34 -0
  210. package/ts_interfaces/data/actionlog.ts +13 -0
  211. package/ts_interfaces/data/branch.ts +9 -0
  212. package/ts_interfaces/data/connection.ts +13 -0
  213. package/ts_interfaces/data/group.ts +10 -0
  214. package/ts_interfaces/data/identity.ts +7 -0
  215. package/ts_interfaces/data/index.ts +11 -0
  216. package/ts_interfaces/data/job.ts +42 -0
  217. package/ts_interfaces/data/managedsecret.ts +41 -0
  218. package/ts_interfaces/data/pipeline.ts +32 -0
  219. package/ts_interfaces/data/project.ts +12 -0
  220. package/ts_interfaces/data/secret.ts +11 -0
  221. package/ts_interfaces/data/sync.ts +37 -0
  222. package/ts_interfaces/index.ts +9 -0
  223. package/ts_interfaces/plugins.ts +6 -0
  224. package/ts_interfaces/requests/actionlog.ts +19 -0
  225. package/ts_interfaces/requests/actions.ts +39 -0
  226. package/ts_interfaces/requests/admin.ts +43 -0
  227. package/ts_interfaces/requests/connections.ts +95 -0
  228. package/ts_interfaces/requests/groups.ts +18 -0
  229. package/ts_interfaces/requests/index.ts +13 -0
  230. package/ts_interfaces/requests/jobs.ts +118 -0
  231. package/ts_interfaces/requests/logs.ts +18 -0
  232. package/ts_interfaces/requests/managedsecrets.ts +112 -0
  233. package/ts_interfaces/requests/pipelines.ts +71 -0
  234. package/ts_interfaces/requests/projects.ts +18 -0
  235. package/ts_interfaces/requests/secrets.ts +92 -0
  236. package/ts_interfaces/requests/sync.ts +157 -0
  237. package/ts_interfaces/requests/webhook.ts +18 -0
  238. package/ts_web/00_commitinfo_data.ts +8 -0
  239. package/ts_web/appstate.ts +1251 -0
  240. package/ts_web/elements/gitops-dashboard.ts +350 -0
  241. package/ts_web/elements/index.ts +10 -0
  242. package/ts_web/elements/shared/css.ts +29 -0
  243. package/ts_web/elements/shared/index.ts +1 -0
  244. package/ts_web/elements/views/actionlog/index.ts +101 -0
  245. package/ts_web/elements/views/actions/index.ts +209 -0
  246. package/ts_web/elements/views/buildlog/index.ts +196 -0
  247. package/ts_web/elements/views/connections/index.ts +260 -0
  248. package/ts_web/elements/views/groups/index.ts +134 -0
  249. package/ts_web/elements/views/jobs/index.ts +424 -0
  250. package/ts_web/elements/views/managedsecrets/index.ts +502 -0
  251. package/ts_web/elements/views/overview/index.ts +86 -0
  252. package/ts_web/elements/views/pipelines/index.ts +561 -0
  253. package/ts_web/elements/views/projects/index.ts +149 -0
  254. package/ts_web/elements/views/secrets/index.ts +310 -0
  255. package/ts_web/elements/views/sync/index.ts +512 -0
  256. package/ts_web/index.ts +7 -0
  257. package/ts_web/plugins.ts +15 -0
  258. package/tsconfig.json +15 -0
@@ -0,0 +1,2117 @@
1
+ import * as plugins from '../plugins.js';
2
+ import { logger } from '../logging.js';
3
+ import { intervalMinutesToMs, unrefTimer, validateIntervalMinutes } from '../timers.js';
4
+ import type { ChildProcess } from 'node:child_process';
5
+ import type * as interfaces from '../../ts_interfaces/index.js';
6
+ import type { ConnectionManager } from './connectionmanager.js';
7
+ import type { ActionLog } from './actionlog.js';
8
+ import type { StorageManager } from '../storage/index.js';
9
+ import type { BaseProvider } from '../providers/classes.baseprovider.js';
10
+
11
+ const SYNC_PREFIX = '/sync/';
12
+ const SYNC_STATUS_PREFIX = '/sync-status/';
13
+
14
+ /**
15
+ * Manages sync configurations and executes periodic git mirror operations.
16
+ * Each sync config defines a source → target connection mapping.
17
+ * Repos are mirrored using bare git repos stored on disk.
18
+ */
19
+ export class SyncManager {
20
+ private configs: interfaces.data.ISyncConfig[] = [];
21
+ private timers: Map<string, ReturnType<typeof setInterval>> = new Map();
22
+ private runningSync: Set<string> = new Set();
23
+ private syncedGroupMeta: Set<string> = new Set();
24
+ private currentSyncConfig: interfaces.data.ISyncConfig | null = null;
25
+ private avatarUploadCache: Map<string, string> = new Map();
26
+ private activeGitChildren = new Set<ChildProcess>();
27
+ private stopping = false;
28
+
29
+ private mirrorsPath = '';
30
+
31
+ constructor(
32
+ private storageManager: StorageManager,
33
+ private connectionManager: ConnectionManager,
34
+ private actionLog: ActionLog,
35
+ ) {}
36
+
37
+ async init(): Promise<void> {
38
+ this.stopping = false;
39
+ // Create temp directory for mirrors (RAM-backed on most Linux systems via tmpfs)
40
+ this.mirrorsPath = await plugins.fs.mkdtemp(plugins.path.join(plugins.os.tmpdir(), 'gitops-mirrors-'));
41
+ await this.loadConfigs();
42
+ for (const config of this.configs) {
43
+ if (config.status === 'active') {
44
+ this.startTimer(config);
45
+ }
46
+ }
47
+ if (this.configs.length > 0) {
48
+ logger.info(`SyncManager loaded ${this.configs.length} sync config(s)`);
49
+ }
50
+ }
51
+
52
+ async stop(): Promise<void> {
53
+ this.stopping = true;
54
+ for (const [_id, timer] of this.timers) {
55
+ clearInterval(timer);
56
+ }
57
+ this.timers.clear();
58
+
59
+ for (const child of this.activeGitChildren) {
60
+ if (child.exitCode === null && child.signalCode === null) {
61
+ child.kill('SIGTERM');
62
+ }
63
+ }
64
+
65
+ if (this.activeGitChildren.size > 0) {
66
+ const forceKillTimer = setTimeout(() => {
67
+ for (const child of this.activeGitChildren) {
68
+ if (child.exitCode === null && child.signalCode === null) {
69
+ child.kill('SIGKILL');
70
+ }
71
+ }
72
+ }, 5000);
73
+ unrefTimer(forceKillTimer);
74
+ await this.waitForRunningSyncs();
75
+ clearTimeout(forceKillTimer);
76
+ } else {
77
+ await this.waitForRunningSyncs();
78
+ }
79
+
80
+ // Clean up temp mirrors directory
81
+ if (this.mirrorsPath) {
82
+ try {
83
+ await plugins.fs.rm(this.mirrorsPath, { recursive: true, force: true });
84
+ } catch { /* may already be gone */ }
85
+ }
86
+ }
87
+
88
+ // ============================================================================
89
+ // CRUD
90
+ // ============================================================================
91
+
92
+ getConfigs(): interfaces.data.ISyncConfig[] {
93
+ return [...this.configs];
94
+ }
95
+
96
+ getConfig(id: string): interfaces.data.ISyncConfig | undefined {
97
+ return this.configs.find((c) => c.id === id);
98
+ }
99
+
100
+ async createConfig(data: {
101
+ name: string;
102
+ sourceConnectionId: string;
103
+ targetConnectionId: string;
104
+ targetGroupOffset?: string;
105
+ intervalMinutes?: number;
106
+ enforceDelete?: boolean;
107
+ enforceGroupDelete?: boolean;
108
+ addMirrorHint?: boolean;
109
+ useGroupAvatarsForProjects?: boolean;
110
+ }): Promise<interfaces.data.ISyncConfig> {
111
+ const config: interfaces.data.ISyncConfig = {
112
+ id: crypto.randomUUID(),
113
+ name: data.name,
114
+ sourceConnectionId: data.sourceConnectionId,
115
+ targetConnectionId: data.targetConnectionId,
116
+ targetGroupOffset: data.targetGroupOffset,
117
+ intervalMinutes: validateIntervalMinutes(data.intervalMinutes, 'sync intervalMinutes', 5),
118
+ status: 'paused',
119
+ lastSyncAt: 0,
120
+ reposSynced: 0,
121
+ enforceDelete: data.enforceDelete ?? false,
122
+ enforceGroupDelete: data.enforceGroupDelete ?? false,
123
+ addMirrorHint: data.addMirrorHint ?? false,
124
+ useGroupAvatarsForProjects: data.useGroupAvatarsForProjects ?? false,
125
+ createdAt: Date.now(),
126
+ };
127
+ this.validateSyncConfig(config);
128
+ this.configs.push(config);
129
+ await this.persistConfig(config);
130
+ logger.success(`Sync config created (paused): ${config.name}`);
131
+ return config;
132
+ }
133
+
134
+ async updateConfig(
135
+ id: string,
136
+ updates: { name?: string; targetGroupOffset?: string; intervalMinutes?: number; enforceDelete?: boolean; enforceGroupDelete?: boolean; addMirrorHint?: boolean; useGroupAvatarsForProjects?: boolean },
137
+ ): Promise<interfaces.data.ISyncConfig> {
138
+ const config = this.configs.find((c) => c.id === id);
139
+ if (!config) throw new Error(`Sync config not found: ${id}`);
140
+ if (updates.name) config.name = updates.name;
141
+ if (updates.intervalMinutes !== undefined) {
142
+ config.intervalMinutes = validateIntervalMinutes(updates.intervalMinutes, 'sync intervalMinutes');
143
+ }
144
+ if (updates.enforceDelete !== undefined) config.enforceDelete = updates.enforceDelete;
145
+ if (updates.enforceGroupDelete !== undefined) config.enforceGroupDelete = updates.enforceGroupDelete;
146
+ if (updates.addMirrorHint !== undefined) config.addMirrorHint = updates.addMirrorHint;
147
+ if (updates.useGroupAvatarsForProjects !== undefined) config.useGroupAvatarsForProjects = updates.useGroupAvatarsForProjects;
148
+ if (updates.targetGroupOffset !== undefined) config.targetGroupOffset = updates.targetGroupOffset;
149
+ this.validateSyncConfig(config);
150
+ await this.persistConfig(config);
151
+ // Restart timer with new interval
152
+ if (config.status === 'active') {
153
+ this.startTimer(config);
154
+ }
155
+ return config;
156
+ }
157
+
158
+ async deleteConfig(id: string): Promise<void> {
159
+ const idx = this.configs.findIndex((c) => c.id === id);
160
+ if (idx === -1) throw new Error(`Sync config not found: ${id}`);
161
+ this.stopTimer(id);
162
+ this.configs.splice(idx, 1);
163
+ await this.storageManager.delete(`${SYNC_PREFIX}${id}.json`);
164
+ // Clean up repo statuses
165
+ const statusKeys = await this.storageManager.list(`${SYNC_STATUS_PREFIX}${id}/`);
166
+ for (const key of statusKeys) {
167
+ await this.storageManager.delete(key);
168
+ }
169
+ // Clean up mirror directory
170
+ const mirrorDir = plugins.path.join(this.mirrorsPath, id);
171
+ try {
172
+ await plugins.fs.rm(mirrorDir, { recursive: true, force: true });
173
+ } catch {
174
+ // Directory may not exist
175
+ }
176
+ logger.info(`Sync config deleted: ${id}`);
177
+ }
178
+
179
+ async pauseConfig(id: string, paused: boolean): Promise<interfaces.data.ISyncConfig> {
180
+ const config = this.configs.find((c) => c.id === id);
181
+ if (!config) throw new Error(`Sync config not found: ${id}`);
182
+ config.status = paused ? 'paused' : 'active';
183
+ await this.persistConfig(config);
184
+ if (paused) {
185
+ this.stopTimer(id);
186
+ } else {
187
+ this.startTimer(config);
188
+ }
189
+ logger.info(`Sync config ${paused ? 'paused' : 'resumed'}: ${config.name}`);
190
+ return config;
191
+ }
192
+
193
+ async getRepoStatuses(syncConfigId: string): Promise<interfaces.data.ISyncRepoStatus[]> {
194
+ const keys = await this.storageManager.list(`${SYNC_STATUS_PREFIX}${syncConfigId}/`);
195
+ const statuses: interfaces.data.ISyncRepoStatus[] = [];
196
+ for (const key of keys) {
197
+ const status = await this.storageManager.getJSON<interfaces.data.ISyncRepoStatus>(key);
198
+ if (status) statuses.push(status);
199
+ }
200
+ return statuses;
201
+ }
202
+
203
+ // ============================================================================
204
+ // Preview
205
+ // ============================================================================
206
+
207
+ async previewSync(configId: string): Promise<{
208
+ mappings: Array<{ sourceFullPath: string; targetFullPath: string }>;
209
+ deletions: string[];
210
+ groupDeletions: string[];
211
+ }> {
212
+ const config = this.configs.find((c) => c.id === configId);
213
+ if (!config) throw new Error(`Sync config not found: ${configId}`);
214
+
215
+ logger.syncLog('info', `Preview started for "${config.name}"`, 'preview');
216
+
217
+ const sourceConn = this.connectionManager.getConnection(config.sourceConnectionId);
218
+ const targetConn = this.connectionManager.getConnection(config.targetConnectionId);
219
+ if (!sourceConn) throw new Error(`Source connection not found: ${config.sourceConnectionId}`);
220
+ if (!targetConn) throw new Error(`Target connection not found: ${config.targetConnectionId}`);
221
+
222
+ const sourceProvider = this.connectionManager.getProvider(config.sourceConnectionId);
223
+ const targetProvider = this.connectionManager.getProvider(config.targetConnectionId);
224
+ logger.syncLog('info', `Fetching source projects from "${sourceConn.name}"...`, 'preview');
225
+ const allProjects = await sourceProvider.getProjects();
226
+ const projects = allProjects.filter(p => !this.isObsoletePath(p.fullPath));
227
+ logger.syncLog('info', `Found ${projects.length} source projects (${allProjects.length - projects.length} obsolete excluded)`, 'preview');
228
+
229
+ const mappings = projects.map((project) => {
230
+ const targetFullPath = this.computeTargetFullPath(
231
+ project.fullPath, sourceConn.groupFilter, config.targetGroupOffset,
232
+ );
233
+ return { sourceFullPath: project.fullPath, targetFullPath };
234
+ });
235
+
236
+ // Compute repo deletions when enforce-delete is enabled
237
+ let deletions: string[] = [];
238
+ if (config.enforceDelete) {
239
+ logger.syncLog('info', 'Computing repo deletions (enforce-delete enabled)...', 'preview');
240
+ const expectedTargetPaths = new Set(
241
+ mappings.map((m) => m.targetFullPath.toLowerCase()),
242
+ );
243
+ const scopePrefix = config.targetGroupOffset;
244
+ const targetProjects = await targetProvider.getProjects();
245
+
246
+ for (const tp of targetProjects) {
247
+ if (this.isObsoletePath(tp.fullPath)) continue;
248
+ if (scopePrefix && !tp.fullPath.toLowerCase().startsWith(scopePrefix.toLowerCase() + '/')) {
249
+ continue;
250
+ }
251
+ if (!expectedTargetPaths.has(tp.fullPath.toLowerCase())) {
252
+ deletions.push(tp.fullPath);
253
+ }
254
+ }
255
+ }
256
+
257
+ // Compute group deletions when enforce-group-delete is enabled
258
+ let groupDeletions: string[] = [];
259
+ if (config.enforceGroupDelete) {
260
+ logger.syncLog('info', 'Computing group deletions (enforce-group-delete enabled)...', 'preview');
261
+ const sourceGroups = await sourceProvider.getGroups();
262
+ const expectedTargetGroups = new Set<string>();
263
+ for (const sg of sourceGroups) {
264
+ const targetPath = this.computeTargetFullPath(
265
+ sg.fullPath, sourceConn.groupFilter, config.targetGroupOffset,
266
+ );
267
+ expectedTargetGroups.add(targetPath.toLowerCase());
268
+ }
269
+ // Also include the offset itself and the obsolete group
270
+ if (config.targetGroupOffset) {
271
+ expectedTargetGroups.add(config.targetGroupOffset.toLowerCase());
272
+ expectedTargetGroups.add(`${config.targetGroupOffset}/obsolete`.toLowerCase());
273
+ }
274
+
275
+ const targetGroups = await targetProvider.getGroups();
276
+ const scopePrefix = config.targetGroupOffset;
277
+
278
+ for (const tg of targetGroups) {
279
+ if (this.isObsoletePath(tg.fullPath)) continue;
280
+ if (scopePrefix && !tg.fullPath.toLowerCase().startsWith(scopePrefix.toLowerCase() + '/')) {
281
+ continue;
282
+ }
283
+ if (!expectedTargetGroups.has(tg.fullPath.toLowerCase())) {
284
+ groupDeletions.push(tg.fullPath);
285
+ }
286
+ }
287
+ }
288
+
289
+ logger.syncLog('success', `Preview complete: ${mappings.length} mappings, ${deletions.length} deletions, ${groupDeletions.length} group deletions`, 'preview');
290
+ return { mappings, deletions, groupDeletions };
291
+ }
292
+
293
+ // ============================================================================
294
+ // Sync Engine
295
+ // ============================================================================
296
+
297
+ async executeSync(configId: string, force = false): Promise<void> {
298
+ if (this.stopping) {
299
+ logger.warn(`SyncManager is stopping, skipping sync ${configId}`);
300
+ return;
301
+ }
302
+
303
+ if (this.runningSync.has(configId)) {
304
+ logger.warn(`Sync ${configId} already running, skipping`);
305
+ return;
306
+ }
307
+
308
+ const config = this.configs.find((c) => c.id === configId);
309
+ if (!config) return;
310
+ if (config.status === 'paused' && !force) return;
311
+
312
+ this.runningSync.add(configId);
313
+ this.syncedGroupMeta.clear();
314
+ this.currentSyncConfig = config;
315
+ const startTime = Date.now();
316
+ logger.syncLog('info', `Sync started for "${config.name}"`, 'sync');
317
+
318
+ try {
319
+ const sourceConn = this.connectionManager.getConnection(config.sourceConnectionId);
320
+ const targetConn = this.connectionManager.getConnection(config.targetConnectionId);
321
+ if (!sourceConn) throw new Error(`Source connection not found: ${config.sourceConnectionId}`);
322
+ if (!targetConn) throw new Error(`Target connection not found: ${config.targetConnectionId}`);
323
+
324
+ this.validateSyncConfig(config);
325
+
326
+ // Get all projects from source
327
+ const sourceProvider = this.connectionManager.getProvider(config.sourceConnectionId);
328
+ logger.syncLog('info', `Fetching source projects from "${sourceConn.name}"...`, 'api');
329
+ const allProjects = await sourceProvider.getProjects();
330
+ const projects = allProjects.filter(p => !this.isObsoletePath(p.fullPath));
331
+ logger.syncLog('info', `Found ${projects.length} source projects (${allProjects.length - projects.length} obsolete excluded)`, 'api');
332
+
333
+ let synced = 0;
334
+ const CONCURRENCY = 10;
335
+ for (let i = 0; i < projects.length; i += CONCURRENCY) {
336
+ const batch = projects.slice(i, i + CONCURRENCY);
337
+ await Promise.all(batch.map(async (project) => {
338
+ try {
339
+ logger.syncLog('info', `Syncing ${project.fullPath}...`, 'sync');
340
+ await this.syncRepo(config, project, sourceConn, targetConn);
341
+ synced++;
342
+ await this.updateRepoStatus(config.id, project.fullPath, {
343
+ status: 'synced',
344
+ lastSyncAt: Date.now(),
345
+ lastSyncError: undefined,
346
+ });
347
+ logger.syncLog('success', `Synced ${project.fullPath}`, 'sync');
348
+ } catch (err) {
349
+ const errMsg = err instanceof Error ? err.message : String(err);
350
+ await this.updateRepoStatus(config.id, project.fullPath, {
351
+ status: 'error',
352
+ lastSyncError: errMsg,
353
+ lastSyncAt: Date.now(),
354
+ });
355
+ logger.syncLog('error', `Sync failed for ${project.fullPath}: ${errMsg}`, 'sync');
356
+ }
357
+ }));
358
+ }
359
+
360
+ // Enforce deletion: move stale target repos to obsolete
361
+ if (config.enforceDelete) {
362
+ logger.syncLog('info', 'Checking for stale target repos...', 'sync');
363
+ await this.enforceDeleteStaleRepos(config, projects, sourceConn, targetConn);
364
+ }
365
+
366
+ // Enforce group deletion: move stale target groups to obsolete
367
+ if (config.enforceGroupDelete) {
368
+ logger.syncLog('info', 'Checking for stale target groups...', 'sync');
369
+ await this.enforceDeleteStaleGroups(config, sourceConn, targetConn);
370
+ }
371
+
372
+ config.lastSyncAt = Date.now();
373
+ config.reposSynced = synced;
374
+ config.lastSyncDurationMs = Date.now() - startTime;
375
+ config.lastSyncError = undefined;
376
+ if (config.status === 'error') config.status = 'active';
377
+ await this.persistConfig(config);
378
+
379
+ logger.syncLog('success', `Sync complete for "${config.name}": ${synced}/${projects.length} repos in ${config.lastSyncDurationMs}ms`, 'sync');
380
+ } catch (err) {
381
+ const errMsg = err instanceof Error ? err.message : String(err);
382
+ config.lastSyncError = errMsg;
383
+ config.lastSyncAt = Date.now();
384
+ config.lastSyncDurationMs = Date.now() - startTime;
385
+ config.status = 'error';
386
+ await this.persistConfig(config);
387
+ logger.syncLog('error', `Sync config "${config.name}" failed: ${errMsg}`, 'sync');
388
+ } finally {
389
+ this.runningSync.delete(configId);
390
+ }
391
+ }
392
+
393
+ // ============================================================================
394
+ // Single Repo Sync
395
+ // ============================================================================
396
+
397
+ private async syncRepo(
398
+ config: interfaces.data.ISyncConfig,
399
+ project: interfaces.data.IProject,
400
+ sourceConn: interfaces.data.IProviderConnection,
401
+ targetConn: interfaces.data.IProviderConnection,
402
+ ): Promise<void> {
403
+ const targetFullPath = this.computeTargetFullPath(
404
+ project.fullPath, sourceConn.groupFilter, config.targetGroupOffset,
405
+ );
406
+
407
+ // Build authenticated git URLs
408
+ const sourceUrl = this.buildAuthUrl(sourceConn, project.fullPath);
409
+ const targetUrl = this.buildAuthUrl(targetConn, targetFullPath);
410
+
411
+ // Mirror directory for this repo
412
+ const mirrorDir = plugins.path.join(
413
+ this.mirrorsPath,
414
+ config.id,
415
+ this.sanitizePath(project.fullPath),
416
+ );
417
+
418
+ // Ensure target group/project hierarchy exists
419
+ await this.ensureTargetExists(targetConn, targetFullPath, project, sourceConn, sourceConn.groupFilter, config.targetGroupOffset);
420
+
421
+ // API-based ref comparison (fast path — avoids git clone when refs already match)
422
+ const sourceProvider = this.connectionManager.getProvider(sourceConn.id);
423
+ const targetProvider = this.connectionManager.getProvider(targetConn.id);
424
+ const apiRefsMatch = await this.refsMatchViaApi(
425
+ sourceProvider, targetProvider, project.fullPath, targetFullPath,
426
+ );
427
+ if (apiRefsMatch === true) {
428
+ logger.syncLog('info', `Refs match via API for ${project.fullPath}, skipping git`, 'api');
429
+ await this.syncProjectMetadata(config, sourceConn, targetConn, project.fullPath, targetFullPath);
430
+ return;
431
+ }
432
+
433
+ // Clone or fetch from source
434
+ try {
435
+ const exists = await this.dirExists(mirrorDir);
436
+ if (!exists) {
437
+ await plugins.fs.mkdir(mirrorDir, { recursive: true });
438
+ await this.runGit(['clone', '--bare', sourceUrl, '.'], mirrorDir);
439
+ }
440
+ // Ensure fetch refspec is configured (bare clones don't set one by default,
441
+ // which prevents tracking branch renames like master -> main)
442
+ await this.runGit(
443
+ ['config', 'remote.origin.fetch', '+refs/heads/*:refs/heads/*'], mirrorDir,
444
+ );
445
+ // Update source remote URL in case connection changed
446
+ try {
447
+ await this.runGit(['remote', 'set-url', 'origin', sourceUrl], mirrorDir);
448
+ } catch {
449
+ // Ignore errors
450
+ }
451
+ // Fetch latest refs from source (--prune removes branches deleted on remote)
452
+ await this.runGit(['fetch', '--prune', 'origin'], mirrorDir);
453
+ } catch (err: any) {
454
+ const msg = err instanceof Error ? err.message : String(err);
455
+ if (msg.includes("couldn't find remote ref HEAD")) {
456
+ logger.syncLog('warn', `Skipping empty repo ${project.fullPath} (no HEAD ref)`, 'git');
457
+ return;
458
+ }
459
+ throw err;
460
+ }
461
+
462
+ // Set up target remote and push
463
+ const remotes = await this.runGit(['remote'], mirrorDir);
464
+ if (!remotes.includes('target')) {
465
+ await this.runGit(['remote', 'add', 'target', targetUrl], mirrorDir);
466
+ } else {
467
+ await this.runGit(['remote', 'set-url', 'target', targetUrl], mirrorDir);
468
+ }
469
+
470
+ // Check for unrelated history before mirror-pushing
471
+ const isUnrelated = await this.checkUnrelatedHistory(mirrorDir);
472
+ if (isUnrelated) {
473
+ logger.syncLog('warn', `Target "${targetFullPath}" has unrelated history — moving to obsolete`, 'git');
474
+ await this.moveToObsolete(targetConn, targetFullPath, config.targetGroupOffset);
475
+ // Re-create fresh target
476
+ await this.ensureTargetExists(targetConn, targetFullPath, project, sourceConn, sourceConn.groupFilter, config.targetGroupOffset);
477
+ this.actionLog.append({
478
+ actionType: 'obsolete',
479
+ entityType: 'sync',
480
+ entityId: config.id,
481
+ entityName: config.name,
482
+ details: `Moved unrelated repo "${targetFullPath}" to obsolete`,
483
+ username: 'system',
484
+ });
485
+ }
486
+
487
+ // Compare refs to determine if push is needed
488
+ const refsAlreadyMatch = !isUnrelated && await this.refsMatch(mirrorDir);
489
+
490
+ if (refsAlreadyMatch) {
491
+ logger.syncLog('info', `Refs already match for ${project.fullPath}, skipping push`, 'api');
492
+ } else {
493
+ // Phase 1: push all refs without pruning (ensures target has all source branches)
494
+ await this.runGit([
495
+ 'push', 'target',
496
+ '+refs/heads/*:refs/heads/*',
497
+ '+refs/tags/*:refs/tags/*',
498
+ ], mirrorDir);
499
+
500
+ // Phase 2: sync default_branch now that all branches exist on target
501
+ await this.syncDefaultBranchBeforePush(sourceConn, targetConn, project.fullPath, targetFullPath);
502
+
503
+ // Phase 2b: unprotect stale branches on target so --prune can delete them
504
+ await this.unprotectStaleBranches(targetConn, targetFullPath, mirrorDir);
505
+
506
+ // Phase 3: push with --prune to remove stale branches (safe now that default_branch is correct)
507
+ await this.runGit([
508
+ 'push', 'target',
509
+ '+refs/heads/*:refs/heads/*',
510
+ '+refs/tags/*:refs/tags/*',
511
+ '--prune',
512
+ ], mirrorDir);
513
+ }
514
+
515
+ // Sync project metadata (description, visibility, topics, default_branch, avatar)
516
+ await this.syncProjectMetadata(config, sourceConn, targetConn, project.fullPath, targetFullPath);
517
+ }
518
+
519
+ // ============================================================================
520
+ // Target Hierarchy Creation
521
+ // ============================================================================
522
+
523
+ private async ensureTargetExists(
524
+ targetConn: interfaces.data.IProviderConnection,
525
+ targetFullPath: string,
526
+ sourceProject: interfaces.data.IProject,
527
+ sourceConn?: interfaces.data.IProviderConnection,
528
+ sourceGroupFilter?: string,
529
+ targetGroupOffset?: string,
530
+ ): Promise<void> {
531
+ const segments = targetFullPath.split('/');
532
+ const projectName = segments.pop()!;
533
+ const groupSegments = segments;
534
+
535
+ if (targetConn.providerType === 'gitlab') {
536
+ await this.ensureGitLabTarget(targetConn, groupSegments, projectName, sourceProject, sourceConn, sourceGroupFilter, targetGroupOffset);
537
+ } else {
538
+ await this.ensureGiteaTarget(targetConn, groupSegments, projectName, sourceProject, sourceConn, sourceGroupFilter, targetGroupOffset);
539
+ }
540
+ }
541
+
542
+ private async ensureGitLabTarget(
543
+ conn: interfaces.data.IProviderConnection,
544
+ groupSegments: string[],
545
+ projectName: string,
546
+ sourceProject: interfaces.data.IProject,
547
+ sourceConn?: interfaces.data.IProviderConnection,
548
+ sourceGroupFilter?: string,
549
+ targetGroupOffset?: string,
550
+ ): Promise<void> {
551
+ const client = new plugins.gitlabClient.GitLabClient(conn.baseUrl, conn.token);
552
+
553
+ // Walk group hierarchy top-down, creating each if needed
554
+ let parentId: number | undefined = undefined;
555
+ let currentPath = '';
556
+
557
+ for (const segment of groupSegments) {
558
+ currentPath = currentPath ? `${currentPath}/${segment}` : segment;
559
+ try {
560
+ const group = await client.getGroup(currentPath);
561
+ parentId = group.id;
562
+ } catch {
563
+ // Group doesn't exist — create it
564
+ try {
565
+ const newGroup = await client.createGroup(segment, segment, parentId);
566
+ parentId = newGroup.id;
567
+ logger.info(`Created GitLab group: ${currentPath}`);
568
+ } catch (createErr: any) {
569
+ // 409 = already exists (race condition), try fetching again
570
+ if (String(createErr).includes('409') || String(createErr).includes('already')) {
571
+ const group = await client.getGroup(currentPath);
572
+ parentId = group.id;
573
+ } else {
574
+ throw createErr;
575
+ }
576
+ }
577
+ }
578
+
579
+ // Sync group metadata from source (once per group per sync cycle)
580
+ if (sourceConn && !this.syncedGroupMeta.has(currentPath)) {
581
+ const sourceGroupPath = this.reverseTargetGroupPath(currentPath, sourceGroupFilter, targetGroupOffset);
582
+ if (sourceGroupPath) {
583
+ this.syncedGroupMeta.add(currentPath);
584
+ await this.syncGroupMetadata(sourceConn, conn, sourceGroupPath, currentPath);
585
+ }
586
+ }
587
+ }
588
+
589
+ // Create the project if it doesn't exist
590
+ const projectPath = groupSegments.length > 0
591
+ ? `${groupSegments.join('/')}/${projectName}`
592
+ : projectName;
593
+
594
+ try {
595
+ // Check if project exists by path
596
+ await client.getGroup(projectPath);
597
+ // If this succeeds, it's actually a group, not a project... unlikely but handle
598
+ } catch {
599
+ // Project doesn't exist as a group path; try creating it
600
+ try {
601
+ await client.createProject(projectName, {
602
+ path: projectName,
603
+ namespaceId: parentId,
604
+ description: sourceProject.description,
605
+ visibility: sourceProject.visibility || 'private',
606
+ });
607
+ logger.info(`Created GitLab project: ${projectPath}`);
608
+ } catch (createErr: any) {
609
+ // Already exists is fine
610
+ if (!String(createErr).includes('409') && !String(createErr).includes('already been taken')) {
611
+ throw createErr;
612
+ }
613
+ }
614
+ }
615
+ }
616
+
617
+ private async ensureGiteaTarget(
618
+ conn: interfaces.data.IProviderConnection,
619
+ groupSegments: string[],
620
+ projectName: string,
621
+ sourceProject: interfaces.data.IProject,
622
+ sourceConn?: interfaces.data.IProviderConnection,
623
+ sourceGroupFilter?: string,
624
+ targetGroupOffset?: string,
625
+ ): Promise<void> {
626
+ const client = new plugins.giteaClient.GiteaClient(conn.baseUrl, conn.token);
627
+
628
+ // Gitea has flat orgs (no nesting). Use the first segment as org name.
629
+ // If there are nested segments, join them into the repo name.
630
+ const orgName = groupSegments[0] || conn.groupFilter || 'default';
631
+ const repoName = groupSegments.length > 1
632
+ ? [...groupSegments.slice(1), projectName].join('-')
633
+ : projectName;
634
+
635
+ // Ensure org exists
636
+ try {
637
+ await client.getOrg(orgName);
638
+ } catch {
639
+ try {
640
+ await client.createOrg(orgName, { visibility: 'public' });
641
+ logger.info(`Created Gitea org: ${orgName}`);
642
+ } catch (createErr: any) {
643
+ if (!String(createErr).includes('409') && !String(createErr).includes('already')) {
644
+ throw createErr;
645
+ }
646
+ }
647
+ }
648
+
649
+ // Sync org metadata from source if we have the source connection
650
+ if (sourceConn && groupSegments[0] && !this.syncedGroupMeta.has(groupSegments[0])) {
651
+ const sourceGroupPath = this.reverseTargetGroupPath(groupSegments[0], sourceGroupFilter, targetGroupOffset);
652
+ if (sourceGroupPath) {
653
+ this.syncedGroupMeta.add(groupSegments[0]);
654
+ await this.syncGroupMetadata(sourceConn, conn, sourceGroupPath, groupSegments[0]);
655
+ }
656
+ }
657
+
658
+ // Ensure repo exists in org
659
+ try {
660
+ await client.createOrgRepo(orgName, repoName, {
661
+ description: sourceProject.description,
662
+ private: sourceProject.visibility !== 'public',
663
+ });
664
+ logger.info(`Created Gitea repo: ${orgName}/${repoName}`);
665
+ } catch (createErr: any) {
666
+ // Already exists is fine
667
+ if (!String(createErr).includes('409') && !String(createErr).includes('already')) {
668
+ throw createErr;
669
+ }
670
+ }
671
+ }
672
+
673
+ // ============================================================================
674
+ // Enforce Delete — remove stale target repos
675
+ // ============================================================================
676
+
677
+ private async enforceDeleteStaleRepos(
678
+ config: interfaces.data.ISyncConfig,
679
+ sourceProjects: interfaces.data.IProject[],
680
+ sourceConn: interfaces.data.IProviderConnection,
681
+ targetConn: interfaces.data.IProviderConnection,
682
+ ): Promise<void> {
683
+ // Build set of expected target fullPaths from source
684
+ const expectedTargetPaths = new Set<string>();
685
+ for (const project of sourceProjects) {
686
+ const targetFullPath = this.computeTargetFullPath(
687
+ project.fullPath, sourceConn.groupFilter, config.targetGroupOffset,
688
+ );
689
+ expectedTargetPaths.add(targetFullPath.toLowerCase());
690
+ }
691
+
692
+ // Scope prefix — only delete repos under this prefix
693
+ const scopePrefix = config.targetGroupOffset;
694
+
695
+ // List all target projects (filtered by connection's groupFilter)
696
+ const targetProvider = this.connectionManager.getProvider(config.targetConnectionId);
697
+ const targetProjects = await targetProvider.getProjects();
698
+
699
+ // Delete target projects not in the expected set, scoped to the prefix
700
+ for (const targetProject of targetProjects) {
701
+ if (this.isObsoletePath(targetProject.fullPath)) continue;
702
+ // Skip repos outside our managed prefix
703
+ if (scopePrefix && !targetProject.fullPath.toLowerCase().startsWith(scopePrefix.toLowerCase() + '/')) {
704
+ continue;
705
+ }
706
+ if (!expectedTargetPaths.has(targetProject.fullPath.toLowerCase())) {
707
+ try {
708
+ await this.moveToObsolete(targetConn, targetProject.fullPath, config.targetGroupOffset);
709
+ logger.syncLog('warn', `Moved stale target repo "${targetProject.fullPath}" to obsolete`, 'sync');
710
+ this.actionLog.append({
711
+ actionType: 'obsolete',
712
+ entityType: 'sync',
713
+ entityId: config.id,
714
+ entityName: config.name,
715
+ details: `Moved stale target repo "${targetProject.fullPath}" to obsolete`,
716
+ username: 'system',
717
+ });
718
+ } catch (err) {
719
+ const errMsg = err instanceof Error ? err.message : String(err);
720
+ logger.syncLog('error', `Enforce-delete failed for "${targetProject.fullPath}": ${errMsg}`, 'sync');
721
+ }
722
+ }
723
+ }
724
+ }
725
+
726
+ // ============================================================================
727
+ // Enforce Delete — remove stale target groups/orgs
728
+ // ============================================================================
729
+
730
+ private async enforceDeleteStaleGroups(
731
+ config: interfaces.data.ISyncConfig,
732
+ sourceConn: interfaces.data.IProviderConnection,
733
+ targetConn: interfaces.data.IProviderConnection,
734
+ ): Promise<void> {
735
+ const sourceProvider = this.connectionManager.getProvider(config.sourceConnectionId);
736
+ const targetProvider = this.connectionManager.getProvider(config.targetConnectionId);
737
+
738
+ // Build expected target group paths from source groups
739
+ const sourceGroups = await sourceProvider.getGroups();
740
+ const expectedTargetGroups = new Set<string>();
741
+ for (const sg of sourceGroups) {
742
+ const targetPath = this.computeTargetFullPath(
743
+ sg.fullPath, sourceConn.groupFilter, config.targetGroupOffset,
744
+ );
745
+ expectedTargetGroups.add(targetPath.toLowerCase());
746
+ }
747
+ // Always keep the offset itself and the obsolete group
748
+ if (config.targetGroupOffset) {
749
+ expectedTargetGroups.add(config.targetGroupOffset.toLowerCase());
750
+ expectedTargetGroups.add(`${config.targetGroupOffset}/obsolete`.toLowerCase());
751
+ }
752
+
753
+ // Find stale groups
754
+ const targetGroups = await targetProvider.getGroups();
755
+ const scopePrefix = config.targetGroupOffset;
756
+ const staleGroups: interfaces.data.IGroup[] = [];
757
+
758
+ for (const tg of targetGroups) {
759
+ if (this.isObsoletePath(tg.fullPath)) continue;
760
+ if (scopePrefix && !tg.fullPath.toLowerCase().startsWith(scopePrefix.toLowerCase() + '/')) {
761
+ continue;
762
+ }
763
+ if (!expectedTargetGroups.has(tg.fullPath.toLowerCase())) {
764
+ staleGroups.push(tg);
765
+ }
766
+ }
767
+
768
+ if (staleGroups.length === 0) return;
769
+
770
+ // Sort by path depth (shallowest first) to move top-level groups first
771
+ staleGroups.sort((a, b) => {
772
+ const depthA = a.fullPath.split('/').length;
773
+ const depthB = b.fullPath.split('/').length;
774
+ return depthA - depthB;
775
+ });
776
+
777
+ // Track moved prefixes to skip children already moved with their parent
778
+ const movedPrefixes: string[] = [];
779
+
780
+ for (const group of staleGroups) {
781
+ // Skip if a parent was already moved (GitLab moves children along)
782
+ const isChildOfMoved = movedPrefixes.some(
783
+ (prefix) => group.fullPath.toLowerCase().startsWith(prefix.toLowerCase() + '/'),
784
+ );
785
+ if (isChildOfMoved && targetConn.providerType === 'gitlab') continue;
786
+
787
+ try {
788
+ if (targetConn.providerType === 'gitlab') {
789
+ await this.moveGroupToObsolete(targetConn, group.fullPath, config.targetGroupOffset);
790
+ } else {
791
+ await this.moveGiteaOrgToObsolete(targetConn, group.fullPath, config.targetGroupOffset);
792
+ }
793
+ movedPrefixes.push(group.fullPath);
794
+ logger.syncLog('warn', `Moved stale group "${group.fullPath}" to obsolete`, 'sync');
795
+ this.actionLog.append({
796
+ actionType: 'obsolete',
797
+ entityType: 'sync',
798
+ entityId: config.id,
799
+ entityName: config.name,
800
+ details: `Moved stale target group "${group.fullPath}" to obsolete`,
801
+ username: 'system',
802
+ });
803
+ } catch (err) {
804
+ const errMsg = err instanceof Error ? err.message : String(err);
805
+ logger.syncLog('error', `Enforce-group-delete failed for "${group.fullPath}": ${errMsg}`, 'sync');
806
+ }
807
+ }
808
+ }
809
+
810
+ /**
811
+ * Move a GitLab group to the obsolete group with a unique suffix.
812
+ * Transfers the entire group (including subgroups and projects).
813
+ */
814
+ private async moveGroupToObsolete(
815
+ targetConn: interfaces.data.IProviderConnection,
816
+ groupFullPath: string,
817
+ basePath?: string,
818
+ ): Promise<void> {
819
+ const suffix = this.generateSuffix();
820
+ const obsoleteTarget = await this.ensureObsoleteGroup(targetConn, basePath);
821
+ if (obsoleteTarget.type !== 'gitlab') return;
822
+
823
+ // Get group by path
824
+ const group = await this.rawApiCall(
825
+ targetConn, 'GET',
826
+ `/api/v4/groups/${encodeURIComponent(groupFullPath)}`,
827
+ );
828
+
829
+ // Transfer group to be a child of the obsolete group
830
+ await this.rawApiCall(
831
+ targetConn, 'POST',
832
+ `/api/v4/groups/${group.id}/transfer`,
833
+ { group_id: obsoleteTarget.groupId },
834
+ );
835
+
836
+ // Rename with suffix + set private
837
+ const originalPath = groupFullPath.split('/').pop()!;
838
+ await this.rawApiCall(
839
+ targetConn, 'PUT',
840
+ `/api/v4/groups/${group.id}`,
841
+ { name: `${originalPath}-${suffix}`, path: `${originalPath}-${suffix}`, visibility: 'private' },
842
+ );
843
+
844
+ logger.info(`Moved GitLab group "${groupFullPath}" to obsolete as "${originalPath}-${suffix}"`);
845
+ }
846
+
847
+ /**
848
+ * Move all repos in a Gitea org to obsolete, then delete the empty org.
849
+ * Gitea orgs can't be transferred, so we move repos individually.
850
+ */
851
+ private async moveGiteaOrgToObsolete(
852
+ targetConn: interfaces.data.IProviderConnection,
853
+ orgName: string,
854
+ basePath?: string,
855
+ ): Promise<void> {
856
+ // List all repos in the stale org (auto-paginate)
857
+ const allRepos: any[] = [];
858
+ let page = 1;
859
+ const perPage = 50;
860
+ while (true) {
861
+ const repos = await this.rawApiCall(
862
+ targetConn, 'GET',
863
+ `/api/v1/orgs/${encodeURIComponent(orgName)}/repos?page=${page}&limit=${perPage}`,
864
+ );
865
+ const repoList = Array.isArray(repos) ? repos : [];
866
+ allRepos.push(...repoList);
867
+ if (repoList.length < perPage) break;
868
+ page++;
869
+ }
870
+
871
+ // Move each repo to obsolete
872
+ for (const repo of allRepos) {
873
+ try {
874
+ await this.moveToObsolete(targetConn, `${orgName}/${repo.name}`, basePath);
875
+ } catch (err) {
876
+ const errMsg = err instanceof Error ? err.message : String(err);
877
+ logger.error(`Failed to move repo "${orgName}/${repo.name}" to obsolete: ${errMsg}`);
878
+ }
879
+ }
880
+
881
+ // Delete the now-empty org
882
+ try {
883
+ await this.rawApiCall(
884
+ targetConn, 'DELETE',
885
+ `/api/v1/orgs/${encodeURIComponent(orgName)}`,
886
+ );
887
+ logger.info(`Deleted empty Gitea org: ${orgName}`);
888
+ } catch (err) {
889
+ const errMsg = err instanceof Error ? err.message : String(err);
890
+ logger.error(`Failed to delete empty Gitea org "${orgName}": ${errMsg}`);
891
+ }
892
+ }
893
+
894
+ // ============================================================================
895
+ // Helpers
896
+ // ============================================================================
897
+
898
+ /**
899
+ * Returns true if the given full path belongs to an obsolete namespace.
900
+ * Matches path segments named "obsolete" or ending with "-obsolete".
901
+ */
902
+ private isObsoletePath(fullPath: string): boolean {
903
+ const segments = fullPath.toLowerCase().split('/');
904
+ return segments.some(s => s === 'obsolete' || s.endsWith('-obsolete'));
905
+ }
906
+
907
+ private buildAuthUrl(conn: interfaces.data.IProviderConnection, repoPath: string): string {
908
+ const url = new URL(conn.baseUrl);
909
+ if (conn.providerType === 'gitlab') {
910
+ return `${url.protocol}//oauth2:${conn.token}@${url.host}/${repoPath}.git`;
911
+ } else {
912
+ return `${url.protocol}//gitea-token:${conn.token}@${url.host}/${repoPath}.git`;
913
+ }
914
+ }
915
+
916
+ private computeRelativePath(fullPath: string, groupFilter?: string): string {
917
+ if (!groupFilter) return fullPath;
918
+ if (fullPath.startsWith(groupFilter + '/')) {
919
+ return fullPath.substring(groupFilter.length + 1);
920
+ }
921
+ return fullPath;
922
+ }
923
+
924
+ /**
925
+ * Reverse-map a target group path to the corresponding source group path.
926
+ * Returns null if the target path is part of the offset itself (not a source-derived group).
927
+ */
928
+ private reverseTargetGroupPath(
929
+ targetGroupPath: string,
930
+ sourceGroupFilter?: string,
931
+ targetGroupOffset?: string,
932
+ ): string | null {
933
+ let relativePath = targetGroupPath;
934
+
935
+ // Strip the target offset prefix
936
+ if (targetGroupOffset) {
937
+ if (targetGroupPath === targetGroupOffset) {
938
+ // This IS the offset group itself, not a source-derived group
939
+ return null;
940
+ }
941
+ if (targetGroupPath.startsWith(targetGroupOffset + '/')) {
942
+ relativePath = targetGroupPath.substring(targetGroupOffset.length + 1);
943
+ } else {
944
+ // Target path is not under the offset — can't reverse-map
945
+ return null;
946
+ }
947
+ }
948
+
949
+ // Re-add the source group filter prefix
950
+ if (sourceGroupFilter) {
951
+ return `${sourceGroupFilter}/${relativePath}`;
952
+ }
953
+ return relativePath;
954
+ }
955
+
956
+ private computeTargetFullPath(
957
+ sourceFullPath: string,
958
+ sourceGroupFilter?: string,
959
+ targetGroupOffset?: string,
960
+ ): string {
961
+ const relativePath = this.computeRelativePath(sourceFullPath, sourceGroupFilter);
962
+ return targetGroupOffset ? `${targetGroupOffset}/${relativePath}` : relativePath;
963
+ }
964
+
965
+ /**
966
+ * Validates that the sync config's targetGroupOffset is reachable
967
+ * by the target connection's groupFilter. Throws when enforceDelete
968
+ * is on and the offset is outside the filter scope.
969
+ */
970
+ private validateSyncConfig(config: {
971
+ targetConnectionId: string;
972
+ targetGroupOffset?: string;
973
+ intervalMinutes?: number;
974
+ enforceDelete: boolean;
975
+ }): void {
976
+ validateIntervalMinutes(config.intervalMinutes, 'sync intervalMinutes');
977
+ if (!config.targetGroupOffset) return;
978
+ const targetConn = this.connectionManager.getConnection(config.targetConnectionId);
979
+ if (!targetConn?.groupFilter) return;
980
+
981
+ const offset = config.targetGroupOffset.toLowerCase();
982
+ const filter = targetConn.groupFilter.toLowerCase();
983
+ const inScope = offset === filter || offset.startsWith(filter + '/');
984
+
985
+ if (!inScope && config.enforceDelete) {
986
+ throw new Error(
987
+ `Target group offset "${config.targetGroupOffset}" is outside target connection's ` +
988
+ `group filter "${targetConn.groupFilter}". With enforce-delete enabled, the sync ` +
989
+ `engine cannot list repos under the offset. Either change the offset to be within ` +
990
+ `"${targetConn.groupFilter}/...", remove the target's group filter, or disable enforce-delete.`
991
+ );
992
+ }
993
+
994
+ if (!inScope) {
995
+ logger.warn(
996
+ `Target group offset "${config.targetGroupOffset}" is outside target connection's ` +
997
+ `group filter "${targetConn.groupFilter}". Git pushes will work but repos won't ` +
998
+ `appear in the target connection's listings.`
999
+ );
1000
+ }
1001
+ }
1002
+
1003
+ // ============================================================================
1004
+ // Metadata Sync
1005
+ // ============================================================================
1006
+
1007
+ /**
1008
+ * Download binary data (e.g. avatar image) with provider auth headers.
1009
+ * Returns null on 404 or error.
1010
+ */
1011
+ private async rawBinaryFetch(
1012
+ conn: interfaces.data.IProviderConnection,
1013
+ url: string,
1014
+ ): Promise<Uint8Array | null> {
1015
+ try {
1016
+ const headers: Record<string, string> = {};
1017
+ if (conn.providerType === 'gitlab') {
1018
+ headers['PRIVATE-TOKEN'] = conn.token;
1019
+ } else {
1020
+ headers['Authorization'] = `token ${conn.token}`;
1021
+ }
1022
+ const resp = await fetch(url, { headers });
1023
+ if (!resp.ok) {
1024
+ await resp.body?.cancel();
1025
+ return null;
1026
+ }
1027
+ return new Uint8Array(await resp.arrayBuffer());
1028
+ } catch {
1029
+ return null;
1030
+ }
1031
+ }
1032
+
1033
+ /**
1034
+ * Raw multipart call for avatar uploads (GitLab requires multipart FormData).
1035
+ */
1036
+ private async rawMultipartCall(
1037
+ conn: interfaces.data.IProviderConnection,
1038
+ method: string,
1039
+ apiPath: string,
1040
+ formData: FormData,
1041
+ ): Promise<any> {
1042
+ const baseUrl = conn.baseUrl.replace(/\/+$/, '');
1043
+ const url = `${baseUrl}${apiPath}`;
1044
+ const headers: Record<string, string> = {};
1045
+ if (conn.providerType === 'gitlab') {
1046
+ headers['PRIVATE-TOKEN'] = conn.token;
1047
+ } else {
1048
+ headers['Authorization'] = `token ${conn.token}`;
1049
+ }
1050
+ // Do NOT set Content-Type — let fetch set the multipart boundary
1051
+ const resp = await fetch(url, { method, headers, body: formData });
1052
+ if (!resp.ok) {
1053
+ const text = await resp.text();
1054
+ throw new Error(`${method} ${apiPath}: ${resp.status} - ${text}`);
1055
+ }
1056
+ try {
1057
+ return await resp.json();
1058
+ } catch {
1059
+ return undefined;
1060
+ }
1061
+ }
1062
+
1063
+ /**
1064
+ * Normalize visibility values between GitLab and Gitea.
1065
+ * GitLab: "public" | "internal" | "private"
1066
+ * Gitea: "public" | "limited" | "private"
1067
+ */
1068
+ private normalizeVisibility(visibility: string): string {
1069
+ const v = visibility?.toLowerCase() || 'private';
1070
+ if (v === 'limited') return 'internal';
1071
+ return v;
1072
+ }
1073
+
1074
+ /**
1075
+ * Guess MIME type from binary content or URL for avatar uploads.
1076
+ */
1077
+ private guessAvatarMimeType(data: Uint8Array, url: string): string {
1078
+ // Check magic bytes
1079
+ if (data[0] === 0x89 && data[1] === 0x50) return 'image/png';
1080
+ if (data[0] === 0xFF && data[1] === 0xD8) return 'image/jpeg';
1081
+ if (data[0] === 0x47 && data[1] === 0x49) return 'image/gif';
1082
+ // SVG: text-based XML, no magic bytes — check content
1083
+ const textStart = new TextDecoder().decode(data.slice(0, 200));
1084
+ if (textStart.includes('<svg') || textStart.includes('<?xml')) return 'image/svg+xml';
1085
+ // Fallback: check URL extension
1086
+ if (url.includes('.png')) return 'image/png';
1087
+ if (url.includes('.jpg') || url.includes('.jpeg')) return 'image/jpeg';
1088
+ if (url.includes('.gif')) return 'image/gif';
1089
+ if (url.includes('.svg')) return 'image/svg+xml';
1090
+ return 'image/png'; // default
1091
+ }
1092
+
1093
+ /**
1094
+ * Pre-push: ensure target's default_branch matches source so --prune won't delete it.
1095
+ */
1096
+ private async syncDefaultBranchBeforePush(
1097
+ sourceConn: interfaces.data.IProviderConnection,
1098
+ targetConn: interfaces.data.IProviderConnection,
1099
+ sourceFullPath: string,
1100
+ targetFullPath: string,
1101
+ ): Promise<void> {
1102
+ try {
1103
+ const sourceProject = await this.fetchProjectRaw(sourceConn, sourceFullPath);
1104
+ if (!sourceProject) return;
1105
+ const targetProject = await this.fetchProjectRaw(targetConn, targetFullPath);
1106
+ if (!targetProject) return;
1107
+
1108
+ const sourceBranch = sourceProject.default_branch || 'main';
1109
+ const targetBranch = targetProject.default_branch || 'main';
1110
+
1111
+ if (sourceBranch !== targetBranch) {
1112
+ logger.syncLog('info', `Updating default branch for ${targetFullPath}: ${targetBranch} -> ${sourceBranch}`, 'api');
1113
+ if (targetConn.providerType === 'gitlab') {
1114
+ await this.rawApiCall(targetConn, 'PUT', `/api/v4/projects/${targetProject.id}`, {
1115
+ default_branch: sourceBranch,
1116
+ });
1117
+ } else {
1118
+ const segments = targetFullPath.split('/');
1119
+ const repo = segments.pop()!;
1120
+ const owner = segments[0] || '';
1121
+ await this.rawApiCall(targetConn, 'PATCH', `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, {
1122
+ default_branch: sourceBranch,
1123
+ });
1124
+ }
1125
+ }
1126
+ } catch (err) {
1127
+ const errMsg = err instanceof Error ? err.message : String(err);
1128
+ logger.syncLog('warn', `Pre-push default_branch sync failed for ${targetFullPath}: ${errMsg}`, 'api');
1129
+ }
1130
+ }
1131
+
1132
+ /**
1133
+ * Unprotect branches on the target that no longer exist in the source,
1134
+ * so that git push --prune can delete them.
1135
+ */
1136
+ private async unprotectStaleBranches(
1137
+ targetConn: interfaces.data.IProviderConnection,
1138
+ targetFullPath: string,
1139
+ mirrorDir: string,
1140
+ ): Promise<void> {
1141
+ if (targetConn.providerType !== 'gitlab') return;
1142
+ try {
1143
+ const targetProject = await this.fetchProjectRaw(targetConn, targetFullPath);
1144
+ if (!targetProject) return;
1145
+
1146
+ const client = new plugins.gitlabClient.GitLabClient(targetConn.baseUrl, targetConn.token);
1147
+ const protectedBranches = await client.requestGetProtectedBranches(targetProject.id);
1148
+ if (protectedBranches.length === 0) return;
1149
+
1150
+ // Get list of branches in the local mirror (= source branches)
1151
+ const localBranchOutput = await this.runGit(['branch', '--list'], mirrorDir);
1152
+ const localBranches = new Set(
1153
+ localBranchOutput.split('\n').map(b => b.trim().replace(/^\* /, '')).filter(Boolean),
1154
+ );
1155
+
1156
+ for (const pb of protectedBranches) {
1157
+ if (!localBranches.has(pb.name)) {
1158
+ logger.syncLog('info', `Unprotecting stale branch "${pb.name}" on ${targetFullPath}`, 'api');
1159
+ await client.requestUnprotectBranch(targetProject.id, pb.name);
1160
+ }
1161
+ }
1162
+ } catch (err) {
1163
+ const errMsg = err instanceof Error ? err.message : String(err);
1164
+ logger.syncLog('warn', `Failed to unprotect stale branches for ${targetFullPath}: ${errMsg}`, 'api');
1165
+ }
1166
+ }
1167
+
1168
+ /**
1169
+ * Sync project metadata (description, visibility, topics, default_branch, avatar)
1170
+ * from source to target after the git push.
1171
+ */
1172
+ private async syncProjectMetadata(
1173
+ config: interfaces.data.ISyncConfig,
1174
+ sourceConn: interfaces.data.IProviderConnection,
1175
+ targetConn: interfaces.data.IProviderConnection,
1176
+ sourceFullPath: string,
1177
+ targetFullPath: string,
1178
+ ): Promise<void> {
1179
+ try {
1180
+ // Fetch source project raw JSON
1181
+ const sourceProject = await this.fetchProjectRaw(sourceConn, sourceFullPath);
1182
+ if (!sourceProject) return;
1183
+
1184
+ // Fetch target project raw JSON
1185
+ const targetProject = await this.fetchProjectRaw(targetConn, targetFullPath);
1186
+ if (!targetProject) return;
1187
+
1188
+ // Extract normalized metadata from both
1189
+ const sourceMeta = this.extractProjectMeta(sourceConn, sourceProject);
1190
+ const targetMeta = this.extractProjectMeta(targetConn, targetProject);
1191
+
1192
+ // Append mirror hint to description if enabled
1193
+ if (config.addMirrorHint) {
1194
+ const mirrorUrl = `${sourceConn.baseUrl.replace(/\/+$/, '')}/${sourceFullPath}`;
1195
+ sourceMeta.description = `${sourceMeta.description}\n\n(This is a mirror of ${mirrorUrl})`.trim();
1196
+ }
1197
+
1198
+ // Diff and update text metadata
1199
+ const changes: string[] = [];
1200
+
1201
+ if (sourceMeta.description !== targetMeta.description) changes.push('description');
1202
+ if (this.normalizeVisibility(sourceMeta.visibility) !== this.normalizeVisibility(targetMeta.visibility)) changes.push('visibility');
1203
+ if (JSON.stringify([...sourceMeta.topics].sort()) !== JSON.stringify([...targetMeta.topics].sort())) changes.push('topics');
1204
+ if (sourceMeta.defaultBranch !== targetMeta.defaultBranch) changes.push('default_branch');
1205
+
1206
+ if (changes.length > 0) {
1207
+ logger.syncLog('info', `Syncing metadata for ${targetFullPath}: ${changes.join(', ')}`, 'api');
1208
+ await this.updateProjectMeta(targetConn, targetFullPath, targetProject, sourceMeta);
1209
+ logger.syncLog('success', `Updated metadata for ${targetFullPath}: ${changes.join(', ')}`, 'api');
1210
+ }
1211
+
1212
+ // Sync avatar
1213
+ if (sourceMeta.avatarUrl) {
1214
+ await this.syncProjectAvatar(sourceConn, targetConn, sourceFullPath, targetFullPath, sourceMeta.avatarUrl, targetProject);
1215
+ } else if (config.useGroupAvatarsForProjects) {
1216
+ // Project has no avatar — inherit from parent group
1217
+ const groupPath = sourceFullPath.substring(0, sourceFullPath.lastIndexOf('/'));
1218
+ let groupAvatarApplied = false;
1219
+ if (groupPath) {
1220
+ try {
1221
+ const sourceGroup = await this.fetchGroupRaw(sourceConn, groupPath);
1222
+ if (sourceGroup) {
1223
+ const groupMeta = this.extractGroupMeta(sourceConn, sourceGroup);
1224
+ if (groupMeta.avatarUrl) {
1225
+ await this.syncProjectAvatar(sourceConn, targetConn, sourceFullPath, targetFullPath, groupMeta.avatarUrl, targetProject);
1226
+ groupAvatarApplied = true;
1227
+ }
1228
+ }
1229
+ } catch (err) {
1230
+ const errMsg = err instanceof Error ? err.message : String(err);
1231
+ logger.syncLog('warn', `Group avatar sync failed for ${targetFullPath}: ${errMsg}`, 'api');
1232
+ }
1233
+ }
1234
+ // If group also has no avatar, remove target avatar
1235
+ if (!groupAvatarApplied && targetMeta.avatarUrl) {
1236
+ await this.removeProjectAvatar(targetConn, targetFullPath, targetProject);
1237
+ }
1238
+ } else {
1239
+ // No source avatar, no group fallback — remove target avatar if present
1240
+ if (targetMeta.avatarUrl) {
1241
+ await this.removeProjectAvatar(targetConn, targetFullPath, targetProject);
1242
+ }
1243
+ }
1244
+ } catch (err) {
1245
+ const errMsg = err instanceof Error ? err.message : String(err);
1246
+ logger.syncLog('error', `Metadata sync failed for ${targetFullPath}: ${errMsg}`, 'api');
1247
+ }
1248
+ }
1249
+
1250
+ /**
1251
+ * Sync group/org metadata (description, visibility, avatar) from source to target.
1252
+ */
1253
+ private async syncGroupMetadata(
1254
+ sourceConn: interfaces.data.IProviderConnection,
1255
+ targetConn: interfaces.data.IProviderConnection,
1256
+ sourceGroupPath: string,
1257
+ targetGroupPath: string,
1258
+ ): Promise<void> {
1259
+ try {
1260
+ const sourceGroup = await this.fetchGroupRaw(sourceConn, sourceGroupPath);
1261
+ if (!sourceGroup) return;
1262
+
1263
+ const targetGroup = await this.fetchGroupRaw(targetConn, targetGroupPath);
1264
+ if (!targetGroup) return;
1265
+
1266
+ const sourceMeta = this.extractGroupMeta(sourceConn, sourceGroup);
1267
+ const targetMeta = this.extractGroupMeta(targetConn, targetGroup);
1268
+
1269
+ // Append mirror hint to description if enabled
1270
+ if (this.currentSyncConfig?.addMirrorHint) {
1271
+ const mirrorUrl = `${sourceConn.baseUrl.replace(/\/+$/, '')}/${sourceGroupPath}`;
1272
+ sourceMeta.description = `${sourceMeta.description}\n\n(This is a mirror of ${mirrorUrl})`.trim();
1273
+ }
1274
+
1275
+ const changes: string[] = [];
1276
+ if (sourceMeta.description !== targetMeta.description) changes.push('description');
1277
+ if (this.normalizeVisibility(sourceMeta.visibility) !== this.normalizeVisibility(targetMeta.visibility)) changes.push('visibility');
1278
+
1279
+ if (changes.length > 0) {
1280
+ logger.syncLog('info', `Syncing group metadata for ${targetGroupPath}: ${changes.join(', ')}`, 'api');
1281
+ await this.updateGroupMeta(targetConn, targetGroupPath, targetGroup, sourceMeta);
1282
+ logger.syncLog('success', `Updated group metadata for ${targetGroupPath}: ${changes.join(', ')}`, 'api');
1283
+ }
1284
+
1285
+ // Sync avatar
1286
+ if (sourceMeta.avatarUrl) {
1287
+ await this.syncGroupAvatar(sourceConn, targetConn, sourceGroupPath, targetGroupPath, sourceMeta.avatarUrl, targetGroup);
1288
+ } else if (targetMeta.avatarUrl) {
1289
+ await this.removeGroupAvatar(targetConn, targetGroupPath, targetGroup);
1290
+ }
1291
+ } catch (err) {
1292
+ const errMsg = err instanceof Error ? err.message : String(err);
1293
+ logger.syncLog('error', `Group metadata sync failed for ${targetGroupPath}: ${errMsg}`, 'api');
1294
+ }
1295
+ }
1296
+
1297
+ // ---- Raw metadata fetchers ----
1298
+
1299
+ private async fetchProjectRaw(conn: interfaces.data.IProviderConnection, fullPath: string): Promise<any> {
1300
+ if (conn.providerType === 'gitlab') {
1301
+ return await this.rawApiCall(conn, 'GET', `/api/v4/projects/${encodeURIComponent(fullPath)}`);
1302
+ } else {
1303
+ const segments = fullPath.split('/');
1304
+ const repo = segments.pop()!;
1305
+ const owner = segments[0] || '';
1306
+ return await this.rawApiCall(conn, 'GET', `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`);
1307
+ }
1308
+ }
1309
+
1310
+ private async fetchGroupRaw(conn: interfaces.data.IProviderConnection, groupPath: string): Promise<any> {
1311
+ if (conn.providerType === 'gitlab') {
1312
+ return await this.rawApiCall(conn, 'GET', `/api/v4/groups/${encodeURIComponent(groupPath)}`);
1313
+ } else {
1314
+ // Gitea orgs are flat — the group path IS the org name
1315
+ const orgName = groupPath.split('/')[0] || groupPath;
1316
+ return await this.rawApiCall(conn, 'GET', `/api/v1/orgs/${encodeURIComponent(orgName)}`);
1317
+ }
1318
+ }
1319
+
1320
+ // ---- Metadata extractors ----
1321
+
1322
+ private extractProjectMeta(conn: interfaces.data.IProviderConnection, raw: any): {
1323
+ description: string; visibility: string; topics: string[]; defaultBranch: string; avatarUrl: string;
1324
+ } {
1325
+ if (conn.providerType === 'gitlab') {
1326
+ return {
1327
+ description: raw.description || '',
1328
+ visibility: raw.visibility || 'private',
1329
+ topics: raw.topics || [],
1330
+ defaultBranch: raw.default_branch || 'main',
1331
+ avatarUrl: raw.avatar_url || '',
1332
+ };
1333
+ } else {
1334
+ return {
1335
+ description: raw.description || '',
1336
+ visibility: raw.private ? 'private' : 'public',
1337
+ topics: raw.topics || [],
1338
+ defaultBranch: raw.default_branch || 'main',
1339
+ avatarUrl: raw.avatar_url || '',
1340
+ };
1341
+ }
1342
+ }
1343
+
1344
+ private extractGroupMeta(conn: interfaces.data.IProviderConnection, raw: any): {
1345
+ description: string; visibility: string; avatarUrl: string;
1346
+ } {
1347
+ if (conn.providerType === 'gitlab') {
1348
+ return {
1349
+ description: raw.description || '',
1350
+ visibility: raw.visibility || 'private',
1351
+ avatarUrl: raw.avatar_url || '',
1352
+ };
1353
+ } else {
1354
+ return {
1355
+ description: raw.description || '',
1356
+ visibility: raw.visibility || 'public',
1357
+ avatarUrl: raw.avatar_url || '',
1358
+ };
1359
+ }
1360
+ }
1361
+
1362
+ // ---- Metadata updaters ----
1363
+
1364
+ private async updateProjectMeta(
1365
+ conn: interfaces.data.IProviderConnection,
1366
+ fullPath: string,
1367
+ rawProject: any,
1368
+ meta: { description: string; visibility: string; topics: string[]; defaultBranch: string },
1369
+ ): Promise<void> {
1370
+ if (conn.providerType === 'gitlab') {
1371
+ // Update description, visibility, topics (always safe)
1372
+ await this.rawApiCall(conn, 'PUT', `/api/v4/projects/${rawProject.id}`, {
1373
+ description: meta.description,
1374
+ visibility: this.normalizeVisibility(meta.visibility),
1375
+ topics: meta.topics,
1376
+ });
1377
+ // Update default_branch separately — may fail if the branch doesn't exist in git
1378
+ try {
1379
+ await this.rawApiCall(conn, 'PUT', `/api/v4/projects/${rawProject.id}`, {
1380
+ default_branch: meta.defaultBranch,
1381
+ });
1382
+ } catch (err) {
1383
+ const errMsg = err instanceof Error ? err.message : String(err);
1384
+ logger.syncLog('warn', `Could not set default_branch to "${meta.defaultBranch}" for ${fullPath}: ${errMsg}`, 'api');
1385
+ }
1386
+ } else {
1387
+ const segments = fullPath.split('/');
1388
+ const repo = segments.pop()!;
1389
+ const owner = segments[0] || '';
1390
+ const encodedOwner = encodeURIComponent(owner);
1391
+ const encodedRepo = encodeURIComponent(repo);
1392
+ // Update description, visibility
1393
+ await this.rawApiCall(conn, 'PATCH', `/api/v1/repos/${encodedOwner}/${encodedRepo}`, {
1394
+ description: meta.description,
1395
+ private: this.normalizeVisibility(meta.visibility) === 'private',
1396
+ });
1397
+ // Update default_branch separately — may fail if the branch doesn't exist in git
1398
+ try {
1399
+ await this.rawApiCall(conn, 'PATCH', `/api/v1/repos/${encodedOwner}/${encodedRepo}`, {
1400
+ default_branch: meta.defaultBranch,
1401
+ });
1402
+ } catch (err) {
1403
+ const errMsg = err instanceof Error ? err.message : String(err);
1404
+ logger.syncLog('warn', `Could not set default_branch to "${meta.defaultBranch}" for ${fullPath}: ${errMsg}`, 'api');
1405
+ }
1406
+ // Topics are a separate endpoint in Gitea
1407
+ await this.rawApiCall(conn, 'PUT', `/api/v1/repos/${encodedOwner}/${encodedRepo}/topics`, {
1408
+ topics: meta.topics,
1409
+ });
1410
+ }
1411
+ }
1412
+
1413
+ private async updateGroupMeta(
1414
+ conn: interfaces.data.IProviderConnection,
1415
+ groupPath: string,
1416
+ rawGroup: any,
1417
+ meta: { description: string; visibility: string },
1418
+ ): Promise<void> {
1419
+ if (conn.providerType === 'gitlab') {
1420
+ try {
1421
+ await this.rawApiCall(conn, 'PUT', `/api/v4/groups/${rawGroup.id}`, {
1422
+ description: meta.description,
1423
+ visibility: this.normalizeVisibility(meta.visibility),
1424
+ });
1425
+ } catch (err) {
1426
+ const errMsg = err instanceof Error ? err.message : String(err);
1427
+ if (errMsg.includes('visibility_level') || errMsg.includes('visibility')) {
1428
+ logger.syncLog('warn', `Cannot sync visibility for group ${groupPath} (contains projects with higher visibility), syncing description only`, 'api');
1429
+ await this.rawApiCall(conn, 'PUT', `/api/v4/groups/${rawGroup.id}`, {
1430
+ description: meta.description,
1431
+ });
1432
+ } else {
1433
+ throw err;
1434
+ }
1435
+ }
1436
+ } else {
1437
+ const orgName = groupPath.split('/')[0] || groupPath;
1438
+ await this.rawApiCall(conn, 'PATCH', `/api/v1/orgs/${encodeURIComponent(orgName)}`, {
1439
+ description: meta.description,
1440
+ visibility: this.normalizeVisibility(meta.visibility) === 'private' ? 'private' : 'public',
1441
+ });
1442
+ }
1443
+ }
1444
+
1445
+ // ---- Avatar sync ----
1446
+
1447
+ private async syncProjectAvatar(
1448
+ sourceConn: interfaces.data.IProviderConnection,
1449
+ targetConn: interfaces.data.IProviderConnection,
1450
+ _sourceFullPath: string,
1451
+ targetFullPath: string,
1452
+ sourceAvatarUrl: string,
1453
+ targetRawProject: any,
1454
+ ): Promise<void> {
1455
+ // Resolve relative avatar URLs
1456
+ const resolvedSourceUrl = sourceAvatarUrl.startsWith('http')
1457
+ ? sourceAvatarUrl
1458
+ : `${sourceConn.baseUrl.replace(/\/+$/, '')}${sourceAvatarUrl}`;
1459
+
1460
+ const sourceAvatarData = await this.rawBinaryFetch(sourceConn, resolvedSourceUrl);
1461
+ if (!sourceAvatarData || sourceAvatarData.length === 0) return;
1462
+
1463
+ // Skip SVG avatars — not supported by GitLab project endpoints
1464
+ const mimeType = this.guessAvatarMimeType(sourceAvatarData, resolvedSourceUrl);
1465
+ if (mimeType === 'image/svg+xml') {
1466
+ logger.syncLog('warn', `Skipping SVG avatar for ${targetFullPath} (not supported by target)`, 'api');
1467
+ return;
1468
+ }
1469
+
1470
+ // Check in-memory cache: skip if source hasn't changed since last upload
1471
+ const sourceHash = await this.hashBytes(sourceAvatarData);
1472
+ const cacheKey = `project:${targetFullPath}`;
1473
+ if (this.avatarUploadCache.get(cacheKey) === sourceHash) {
1474
+ return; // Source avatar unchanged since last upload
1475
+ }
1476
+
1477
+ // Compare with target's current avatar to avoid unnecessary uploads
1478
+ const targetMeta = this.extractProjectMeta(targetConn, targetRawProject);
1479
+ if (targetMeta.avatarUrl) {
1480
+ try {
1481
+ const resolvedTargetUrl = targetMeta.avatarUrl.startsWith('http')
1482
+ ? targetMeta.avatarUrl
1483
+ : `${targetConn.baseUrl.replace(/\/+$/, '')}${targetMeta.avatarUrl}`;
1484
+ const targetAvatarData = await this.rawBinaryFetch(targetConn, resolvedTargetUrl);
1485
+ if (targetAvatarData && this.binaryEqual(sourceAvatarData, targetAvatarData)) {
1486
+ this.avatarUploadCache.set(cacheKey, sourceHash);
1487
+ return; // Avatars are identical — skip upload
1488
+ }
1489
+ } catch {
1490
+ // Failed to fetch target avatar — proceed with upload as safe fallback
1491
+ }
1492
+ }
1493
+
1494
+ logger.syncLog('info', `Syncing avatar for ${targetFullPath}...`, 'api');
1495
+
1496
+ if (targetConn.providerType === 'gitlab') {
1497
+ // GitLab: multipart upload
1498
+ const blob = new Blob([sourceAvatarData.buffer as ArrayBuffer], { type: mimeType });
1499
+ const ext = mimeType.split('/')[1] || 'png';
1500
+ const formData = new FormData();
1501
+ formData.append('avatar', blob, `avatar.${ext}`);
1502
+ await this.rawMultipartCall(
1503
+ targetConn, 'PUT',
1504
+ `/api/v4/projects/${targetRawProject.id}`,
1505
+ formData,
1506
+ );
1507
+ } else {
1508
+ // Gitea: base64 JSON upload
1509
+ const segments = targetFullPath.split('/');
1510
+ const repo = segments.pop()!;
1511
+ const owner = segments[0] || '';
1512
+ const base64Image = this.uint8ArrayToBase64(sourceAvatarData);
1513
+ await this.rawApiCall(
1514
+ targetConn, 'POST',
1515
+ `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/avatar`,
1516
+ { image: base64Image },
1517
+ );
1518
+ }
1519
+
1520
+ this.avatarUploadCache.set(cacheKey, sourceHash);
1521
+ }
1522
+
1523
+ private async removeProjectAvatar(
1524
+ targetConn: interfaces.data.IProviderConnection,
1525
+ targetFullPath: string,
1526
+ targetRawProject: any,
1527
+ ): Promise<void> {
1528
+ logger.syncLog('info', `Removing avatar from ${targetFullPath}`, 'api');
1529
+ if (targetConn.providerType === 'gitlab') {
1530
+ await this.rawApiCall(targetConn, 'PUT', `/api/v4/projects/${targetRawProject.id}`, {
1531
+ avatar: '',
1532
+ });
1533
+ } else {
1534
+ const segments = targetFullPath.split('/');
1535
+ const repo = segments.pop()!;
1536
+ const owner = segments[0] || '';
1537
+ await this.rawApiCall(targetConn, 'DELETE',
1538
+ `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/avatar`);
1539
+ }
1540
+ }
1541
+
1542
+ private async syncGroupAvatar(
1543
+ sourceConn: interfaces.data.IProviderConnection,
1544
+ targetConn: interfaces.data.IProviderConnection,
1545
+ _sourceGroupPath: string,
1546
+ targetGroupPath: string,
1547
+ sourceAvatarUrl: string,
1548
+ targetRawGroup: any,
1549
+ ): Promise<void> {
1550
+ const resolvedSourceUrl = sourceAvatarUrl.startsWith('http')
1551
+ ? sourceAvatarUrl
1552
+ : `${sourceConn.baseUrl.replace(/\/+$/, '')}${sourceAvatarUrl}`;
1553
+
1554
+ const sourceAvatarData = await this.rawBinaryFetch(sourceConn, resolvedSourceUrl);
1555
+ if (!sourceAvatarData || sourceAvatarData.length === 0) return;
1556
+
1557
+ // Skip SVG avatars — not supported by GitLab project endpoints
1558
+ const mimeType = this.guessAvatarMimeType(sourceAvatarData, resolvedSourceUrl);
1559
+ if (mimeType === 'image/svg+xml') {
1560
+ logger.syncLog('warn', `Skipping SVG avatar for group ${targetGroupPath} (not supported by target)`, 'api');
1561
+ return;
1562
+ }
1563
+
1564
+ // Check in-memory cache: skip if source hasn't changed since last upload
1565
+ const sourceHash = await this.hashBytes(sourceAvatarData);
1566
+ const cacheKey = `group:${targetGroupPath}`;
1567
+ if (this.avatarUploadCache.get(cacheKey) === sourceHash) {
1568
+ return; // Source avatar unchanged since last upload
1569
+ }
1570
+
1571
+ // Compare with target's current avatar to avoid unnecessary uploads
1572
+ const targetMeta = this.extractGroupMeta(targetConn, targetRawGroup);
1573
+ if (targetMeta.avatarUrl) {
1574
+ try {
1575
+ const resolvedTargetUrl = targetMeta.avatarUrl.startsWith('http')
1576
+ ? targetMeta.avatarUrl
1577
+ : `${targetConn.baseUrl.replace(/\/+$/, '')}${targetMeta.avatarUrl}`;
1578
+ const targetAvatarData = await this.rawBinaryFetch(targetConn, resolvedTargetUrl);
1579
+ if (targetAvatarData && this.binaryEqual(sourceAvatarData, targetAvatarData)) {
1580
+ this.avatarUploadCache.set(cacheKey, sourceHash);
1581
+ return; // Avatars are identical — skip upload
1582
+ }
1583
+ } catch {
1584
+ // Failed to fetch target avatar — proceed with upload as safe fallback
1585
+ }
1586
+ }
1587
+
1588
+ logger.syncLog('info', `Syncing avatar for group ${targetGroupPath}...`, 'api');
1589
+
1590
+ if (targetConn.providerType === 'gitlab') {
1591
+ const blob = new Blob([sourceAvatarData.buffer as ArrayBuffer], { type: mimeType });
1592
+ const ext = mimeType.split('/')[1] || 'png';
1593
+ const formData = new FormData();
1594
+ formData.append('avatar', blob, `avatar.${ext}`);
1595
+ await this.rawMultipartCall(
1596
+ targetConn, 'PUT',
1597
+ `/api/v4/groups/${targetRawGroup.id}`,
1598
+ formData,
1599
+ );
1600
+ } else {
1601
+ const orgName = targetGroupPath.split('/')[0] || targetGroupPath;
1602
+ const base64Image = this.uint8ArrayToBase64(sourceAvatarData);
1603
+ await this.rawApiCall(
1604
+ targetConn, 'POST',
1605
+ `/api/v1/orgs/${encodeURIComponent(orgName)}/avatar`,
1606
+ { image: base64Image },
1607
+ );
1608
+ }
1609
+
1610
+ this.avatarUploadCache.set(cacheKey, sourceHash);
1611
+ }
1612
+
1613
+ private async removeGroupAvatar(
1614
+ targetConn: interfaces.data.IProviderConnection,
1615
+ targetGroupPath: string,
1616
+ targetRawGroup: any,
1617
+ ): Promise<void> {
1618
+ logger.syncLog('info', `Removing avatar from group ${targetGroupPath}`, 'api');
1619
+ if (targetConn.providerType === 'gitlab') {
1620
+ await this.rawApiCall(targetConn, 'PUT', `/api/v4/groups/${targetRawGroup.id}`, {
1621
+ avatar: '',
1622
+ });
1623
+ } else {
1624
+ const orgName = targetGroupPath.split('/')[0] || targetGroupPath;
1625
+ await this.rawApiCall(targetConn, 'DELETE',
1626
+ `/api/v1/orgs/${encodeURIComponent(orgName)}/avatar`);
1627
+ }
1628
+ }
1629
+
1630
+ private binaryEqual(a: Uint8Array, b: Uint8Array): boolean {
1631
+ if (a.length !== b.length) return false;
1632
+ for (let i = 0; i < a.length; i++) {
1633
+ if (a[i] !== b[i]) return false;
1634
+ }
1635
+ return true;
1636
+ }
1637
+
1638
+ private async hashBytes(data: Uint8Array): Promise<string> {
1639
+ const hashBuffer = await crypto.subtle.digest('SHA-256', data.buffer as ArrayBuffer);
1640
+ return Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('');
1641
+ }
1642
+
1643
+ private uint8ArrayToBase64(bytes: Uint8Array): string {
1644
+ let binary = '';
1645
+ for (let i = 0; i < bytes.length; i++) {
1646
+ binary += String.fromCharCode(bytes[i]);
1647
+ }
1648
+ return btoa(binary);
1649
+ }
1650
+
1651
+ // ============================================================================
1652
+ // Obsolete — move repos instead of deleting/overwriting
1653
+ // ============================================================================
1654
+
1655
+ /**
1656
+ * Raw HTTP call for API endpoints not supported by the client libraries.
1657
+ */
1658
+ private async rawApiCall(
1659
+ conn: interfaces.data.IProviderConnection,
1660
+ method: string,
1661
+ apiPath: string,
1662
+ body?: any,
1663
+ ): Promise<any> {
1664
+ const baseUrl = conn.baseUrl.replace(/\/+$/, '');
1665
+ const url = `${baseUrl}${apiPath}`;
1666
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' };
1667
+ if (conn.providerType === 'gitlab') {
1668
+ headers['PRIVATE-TOKEN'] = conn.token;
1669
+ } else {
1670
+ headers['Authorization'] = `token ${conn.token}`;
1671
+ }
1672
+ const resp = await fetch(url, {
1673
+ method,
1674
+ headers,
1675
+ body: body ? JSON.stringify(body) : undefined,
1676
+ });
1677
+ if (!resp.ok) {
1678
+ const text = await resp.text();
1679
+ throw new Error(`${method} ${apiPath}: ${resp.status} - ${text}`);
1680
+ }
1681
+ try {
1682
+ return await resp.json();
1683
+ } catch {
1684
+ return undefined;
1685
+ }
1686
+ }
1687
+
1688
+ private generateSuffix(): string {
1689
+ return crypto.randomUUID().substring(0, 6);
1690
+ }
1691
+
1692
+ /**
1693
+ * Ensure an "obsolete" group/org exists under the target base path.
1694
+ * Returns the obsolete group/org identifier needed for transfers.
1695
+ */
1696
+ private async ensureObsoleteGroup(
1697
+ targetConn: interfaces.data.IProviderConnection,
1698
+ basePath?: string,
1699
+ ): Promise<{ type: 'gitlab'; groupId: number } | { type: 'gitea'; orgName: string }> {
1700
+ if (targetConn.providerType === 'gitlab') {
1701
+ const client = new plugins.gitlabClient.GitLabClient(targetConn.baseUrl, targetConn.token);
1702
+
1703
+ // Walk the basePath to find the parent group, then create "obsolete" subgroup
1704
+ let parentId: number | undefined;
1705
+ if (basePath) {
1706
+ const parentGroup = await client.getGroup(basePath);
1707
+ parentId = parentGroup.id;
1708
+ }
1709
+
1710
+ // Try to get existing obsolete group
1711
+ const obsoletePath = basePath ? `${basePath}/obsolete` : 'obsolete';
1712
+ try {
1713
+ const group = await client.getGroup(obsoletePath);
1714
+ return { type: 'gitlab', groupId: group.id };
1715
+ } catch {
1716
+ // Doesn't exist — create it
1717
+ try {
1718
+ const newGroup = await client.createGroup('obsolete', 'obsolete', parentId);
1719
+ // Set to private via raw API (createGroup defaults to private already)
1720
+ logger.info(`Created GitLab obsolete group: ${obsoletePath}`);
1721
+ return { type: 'gitlab', groupId: newGroup.id };
1722
+ } catch (createErr: any) {
1723
+ if (String(createErr).includes('409') || String(createErr).includes('already')) {
1724
+ const group = await client.getGroup(obsoletePath);
1725
+ return { type: 'gitlab', groupId: group.id };
1726
+ }
1727
+ throw createErr;
1728
+ }
1729
+ }
1730
+ } else {
1731
+ // Gitea: flat orgs — create "{org}-obsolete"
1732
+ const client = new plugins.giteaClient.GiteaClient(targetConn.baseUrl, targetConn.token);
1733
+ const segments = basePath ? basePath.split('/') : [];
1734
+ const orgName = segments[0] || targetConn.groupFilter || 'default';
1735
+ const obsoleteOrg = `${orgName}-obsolete`;
1736
+
1737
+ try {
1738
+ await client.getOrg(obsoleteOrg);
1739
+ } catch {
1740
+ try {
1741
+ await client.createOrg(obsoleteOrg, { visibility: 'private' });
1742
+ logger.info(`Created Gitea obsolete org: ${obsoleteOrg}`);
1743
+ } catch (createErr: any) {
1744
+ if (!String(createErr).includes('409') && !String(createErr).includes('already')) {
1745
+ throw createErr;
1746
+ }
1747
+ }
1748
+ }
1749
+ return { type: 'gitea', orgName: obsoleteOrg };
1750
+ }
1751
+ }
1752
+
1753
+ /**
1754
+ * Move a target repo to the "obsolete" group with a unique suffix.
1755
+ * The project is also set to private.
1756
+ */
1757
+ private async moveToObsolete(
1758
+ targetConn: interfaces.data.IProviderConnection,
1759
+ targetFullPath: string,
1760
+ basePath?: string,
1761
+ ): Promise<void> {
1762
+ const suffix = this.generateSuffix();
1763
+ const obsoleteTarget = await this.ensureObsoleteGroup(targetConn, basePath);
1764
+
1765
+ if (obsoleteTarget.type === 'gitlab') {
1766
+ // 1. Get project by path
1767
+ const project = await this.rawApiCall(
1768
+ targetConn, 'GET',
1769
+ `/api/v4/projects/${encodeURIComponent(targetFullPath)}`,
1770
+ );
1771
+ const projectId = project.id;
1772
+
1773
+ // 2. Transfer to obsolete group
1774
+ await this.rawApiCall(
1775
+ targetConn, 'PUT',
1776
+ `/api/v4/projects/${projectId}/transfer`,
1777
+ { namespace: obsoleteTarget.groupId },
1778
+ );
1779
+
1780
+ // 3. Rename with suffix + set private
1781
+ const originalPath = targetFullPath.split('/').pop()!;
1782
+ await this.rawApiCall(
1783
+ targetConn, 'PUT',
1784
+ `/api/v4/projects/${projectId}`,
1785
+ { name: `${originalPath}-${suffix}`, path: `${originalPath}-${suffix}`, visibility: 'private' },
1786
+ );
1787
+
1788
+ logger.info(`Moved GitLab project "${targetFullPath}" to obsolete as "${originalPath}-${suffix}"`);
1789
+ } else {
1790
+ // Gitea: parse owner/repo
1791
+ const segments = targetFullPath.split('/');
1792
+ const repo = segments.pop()!;
1793
+ const owner = segments[0] || '';
1794
+
1795
+ // 1. Transfer to obsolete org
1796
+ await this.rawApiCall(
1797
+ targetConn, 'POST',
1798
+ `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/transfer`,
1799
+ { new_owner: obsoleteTarget.orgName },
1800
+ );
1801
+
1802
+ // 2. Rename with suffix + set private
1803
+ await this.rawApiCall(
1804
+ targetConn, 'PATCH',
1805
+ `/api/v1/repos/${encodeURIComponent(obsoleteTarget.orgName)}/${encodeURIComponent(repo)}`,
1806
+ { name: `${repo}-${suffix}`, private: true },
1807
+ );
1808
+
1809
+ logger.info(`Moved Gitea repo "${targetFullPath}" to obsolete as "${repo}-${suffix}"`);
1810
+ }
1811
+ }
1812
+
1813
+ /**
1814
+ * Check whether the target remote in a bare mirror dir has unrelated history
1815
+ * compared to the source (origin). Returns true if histories are completely disjoint.
1816
+ */
1817
+ private async checkUnrelatedHistory(mirrorDir: string): Promise<boolean> {
1818
+ // Fetch target refs into the bare mirror
1819
+ try {
1820
+ await this.runGit(['fetch', 'target'], mirrorDir);
1821
+ } catch {
1822
+ // Target is empty or unreachable — not unrelated
1823
+ return false;
1824
+ }
1825
+
1826
+ // Get any source ref
1827
+ const sourceRefs = await this.runGit(
1828
+ ['for-each-ref', '--format=%(objectname)', 'refs/heads/'], mirrorDir,
1829
+ );
1830
+ const sourceRef = sourceRefs.trim().split('\n')[0];
1831
+ if (!sourceRef) return false; // Source is empty
1832
+
1833
+ // Get any target ref
1834
+ const targetRefs = await this.runGit(
1835
+ ['for-each-ref', '--format=%(objectname)', 'refs/remotes/target/'], mirrorDir,
1836
+ );
1837
+ const targetRef = targetRefs.trim().split('\n')[0];
1838
+ if (!targetRef) return false; // Target is empty
1839
+
1840
+ // Check for common ancestor
1841
+ try {
1842
+ await this.runGit(['merge-base', sourceRef, targetRef], mirrorDir);
1843
+ return false; // Common ancestor found — related
1844
+ } catch {
1845
+ return true; // No common ancestor — unrelated
1846
+ }
1847
+ }
1848
+
1849
+ private sanitizePath(fullPath: string): string {
1850
+ return fullPath.replace(/[^a-zA-Z0-9._/-]/g, '_');
1851
+ }
1852
+
1853
+ private async dirExists(dirPath: string): Promise<boolean> {
1854
+ try {
1855
+ const stat = await plugins.fs.stat(dirPath);
1856
+ return stat.isDirectory();
1857
+ } catch {
1858
+ return false;
1859
+ }
1860
+ }
1861
+
1862
+ /**
1863
+ * Fetch all branch and tag SHAs from a repo via provider API.
1864
+ * Returns null on any error (safe fallback to git-based comparison).
1865
+ */
1866
+ private async listRefsViaProvider(
1867
+ provider: BaseProvider,
1868
+ fullPath: string,
1869
+ ): Promise<{ branches: Map<string, string>; tags: Map<string, string> } | null> {
1870
+ try {
1871
+ const [branches, tags] = await Promise.all([
1872
+ provider.getBranches(fullPath),
1873
+ provider.getTags(fullPath),
1874
+ ]);
1875
+ return {
1876
+ branches: new Map(branches.map((b) => [b.name, b.commitSha])),
1877
+ tags: new Map(tags.map((t) => [t.name, t.commitSha])),
1878
+ };
1879
+ } catch {
1880
+ return null;
1881
+ }
1882
+ }
1883
+
1884
+ /**
1885
+ * Compare refs between source and target via provider API (no git clone needed).
1886
+ * Returns true (match), false (differ), or null (can't determine — fall through to git).
1887
+ */
1888
+ private async refsMatchViaApi(
1889
+ sourceProvider: BaseProvider,
1890
+ targetProvider: BaseProvider,
1891
+ sourceFullPath: string,
1892
+ targetFullPath: string,
1893
+ ): Promise<boolean | null> {
1894
+ const [sourceRefs, targetRefs] = await Promise.all([
1895
+ this.listRefsViaProvider(sourceProvider, sourceFullPath),
1896
+ this.listRefsViaProvider(targetProvider, targetFullPath),
1897
+ ]);
1898
+ if (!sourceRefs || !targetRefs) return null;
1899
+
1900
+ // Compare branches
1901
+ if (sourceRefs.branches.size !== targetRefs.branches.size) return false;
1902
+ for (const [name, sha] of sourceRefs.branches) {
1903
+ if (targetRefs.branches.get(name) !== sha) return false;
1904
+ }
1905
+
1906
+ // Compare tags
1907
+ if (sourceRefs.tags.size !== targetRefs.tags.size) return false;
1908
+ for (const [name, sha] of sourceRefs.tags) {
1909
+ if (targetRefs.tags.get(name) !== sha) return false;
1910
+ }
1911
+
1912
+ return true;
1913
+ }
1914
+
1915
+ /**
1916
+ * Compare local refs (source) with target remote refs.
1917
+ * Returns true when all branches and tags are identical — no push needed.
1918
+ */
1919
+ private async refsMatch(mirrorDir: string): Promise<boolean> {
1920
+ try {
1921
+ // Local branches (source)
1922
+ const localHeadsRaw = await this.runGit(
1923
+ ['for-each-ref', '--format=%(refname:strip=2) %(objectname)', 'refs/heads/'], mirrorDir,
1924
+ );
1925
+ // Target branches (fetched by checkUnrelatedHistory)
1926
+ const targetHeadsRaw = await this.runGit(
1927
+ ['for-each-ref', '--format=%(refname:strip=3) %(objectname)', 'refs/remotes/target/'], mirrorDir,
1928
+ );
1929
+
1930
+ // Local tags
1931
+ const localTagsRaw = await this.runGit(
1932
+ ['for-each-ref', '--format=%(refname:strip=2) %(objectname)', 'refs/tags/'], mirrorDir,
1933
+ );
1934
+ // Target tags via ls-remote (avoids shared refs/tags/ namespace ambiguity in bare repos)
1935
+ const targetTagsRaw = await this.runGit(['ls-remote', '--tags', 'target'], mirrorDir);
1936
+
1937
+ const parseRefLines = (raw: string): Map<string, string> => {
1938
+ const map = new Map<string, string>();
1939
+ for (const line of raw.trim().split('\n')) {
1940
+ if (!line.trim()) continue;
1941
+ const parts = line.trim().split(/\s+/);
1942
+ if (parts.length >= 2) {
1943
+ map.set(parts[0], parts[1]);
1944
+ }
1945
+ }
1946
+ return map;
1947
+ };
1948
+
1949
+ const parseLsRemoteTags = (raw: string): Map<string, string> => {
1950
+ const map = new Map<string, string>();
1951
+ for (const line of raw.trim().split('\n')) {
1952
+ if (!line.trim()) continue;
1953
+ // Skip ^{} dereference lines
1954
+ if (line.includes('^{}')) continue;
1955
+ const parts = line.trim().split(/\s+/);
1956
+ if (parts.length >= 2) {
1957
+ // parts[0] = sha, parts[1] = refs/tags/name
1958
+ const tagName = parts[1].replace('refs/tags/', '');
1959
+ map.set(tagName, parts[0]);
1960
+ }
1961
+ }
1962
+ return map;
1963
+ };
1964
+
1965
+ const localHeads = parseRefLines(localHeadsRaw);
1966
+ const targetHeads = parseRefLines(targetHeadsRaw);
1967
+ const localTags = parseRefLines(localTagsRaw);
1968
+ const targetTags = parseLsRemoteTags(targetTagsRaw);
1969
+
1970
+ // Compare branches
1971
+ if (localHeads.size !== targetHeads.size) return false;
1972
+ for (const [name, sha] of localHeads) {
1973
+ if (targetHeads.get(name) !== sha) return false;
1974
+ }
1975
+
1976
+ // Compare tags
1977
+ if (localTags.size !== targetTags.size) return false;
1978
+ for (const [name, sha] of localTags) {
1979
+ if (targetTags.get(name) !== sha) return false;
1980
+ }
1981
+
1982
+ return true;
1983
+ } catch {
1984
+ // On any error, fall back to pushing (safe default)
1985
+ return false;
1986
+ }
1987
+ }
1988
+
1989
+ private async runGit(args: string[], cwd?: string): Promise<string> {
1990
+ if (this.stopping) {
1991
+ throw new Error('SyncManager is stopping');
1992
+ }
1993
+
1994
+ return await new Promise<string>((resolve, reject) => {
1995
+ const child = plugins.childProcess.spawn('git', args, {
1996
+ cwd,
1997
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
1998
+ stdio: ['ignore', 'pipe', 'pipe'],
1999
+ });
2000
+ this.activeGitChildren.add(child);
2001
+ const stdoutChunks: Uint8Array[] = [];
2002
+ const stderrChunks: Uint8Array[] = [];
2003
+ let settled = false;
2004
+
2005
+ const finish = (callback: () => void): void => {
2006
+ if (settled) return;
2007
+ settled = true;
2008
+ this.activeGitChildren.delete(child);
2009
+ callback();
2010
+ };
2011
+
2012
+ child.stdout.on('data', (chunk: Uint8Array) => stdoutChunks.push(chunk));
2013
+ child.stderr.on('data', (chunk: Uint8Array) => stderrChunks.push(chunk));
2014
+ child.on('error', (err) => finish(() => reject(err)));
2015
+ child.on('close', (code: number | null, signal: NodeJS.Signals | null) => finish(() => {
2016
+ const stderr = plugins.Buffer.concat(stderrChunks).toString('utf8');
2017
+ if (code !== 0) {
2018
+ const exitInfo = signal ? `signal ${signal}` : `code ${code}`;
2019
+ reject(new Error(`git ${args[0]} failed with ${exitInfo}: ${stderr.trim()}`));
2020
+ return;
2021
+ }
2022
+ resolve(plugins.Buffer.concat(stdoutChunks).toString('utf8'));
2023
+ }));
2024
+ });
2025
+ }
2026
+
2027
+ // ============================================================================
2028
+ // Persistence
2029
+ // ============================================================================
2030
+
2031
+ private async loadConfigs(): Promise<void> {
2032
+ const keys = await this.storageManager.list(SYNC_PREFIX);
2033
+ this.configs = [];
2034
+ for (const key of keys) {
2035
+ const config = await this.storageManager.getJSON<interfaces.data.ISyncConfig>(key);
2036
+ if (config) {
2037
+ try {
2038
+ config.intervalMinutes = validateIntervalMinutes(
2039
+ config.intervalMinutes,
2040
+ `sync "${config.name}" intervalMinutes`,
2041
+ 5,
2042
+ );
2043
+ } catch (err) {
2044
+ config.status = 'error';
2045
+ config.lastSyncError = err instanceof Error ? err.message : String(err);
2046
+ await this.persistConfig(config);
2047
+ }
2048
+ this.configs.push(config);
2049
+ }
2050
+ }
2051
+ }
2052
+
2053
+ private async persistConfig(config: interfaces.data.ISyncConfig): Promise<void> {
2054
+ await this.storageManager.setJSON(`${SYNC_PREFIX}${config.id}.json`, config);
2055
+ }
2056
+
2057
+ private async updateRepoStatus(
2058
+ syncConfigId: string,
2059
+ sourceFullPath: string,
2060
+ updates: Partial<interfaces.data.ISyncRepoStatus>,
2061
+ ): Promise<void> {
2062
+ const hash = this.sanitizePath(sourceFullPath).replace(/\//g, '__');
2063
+ const key = `${SYNC_STATUS_PREFIX}${syncConfigId}/${hash}.json`;
2064
+ let status = await this.storageManager.getJSON<interfaces.data.ISyncRepoStatus>(key);
2065
+ if (!status) {
2066
+ status = {
2067
+ id: hash,
2068
+ syncConfigId,
2069
+ sourceFullPath,
2070
+ targetFullPath: '',
2071
+ lastSyncAt: 0,
2072
+ status: 'pending',
2073
+ };
2074
+ }
2075
+ Object.assign(status, updates);
2076
+ await this.storageManager.setJSON(key, status);
2077
+ }
2078
+
2079
+ // ============================================================================
2080
+ // Timer Management
2081
+ // ============================================================================
2082
+
2083
+ private startTimer(config: interfaces.data.ISyncConfig): void {
2084
+ this.stopTimer(config.id);
2085
+ let intervalMs: number;
2086
+ try {
2087
+ intervalMs = intervalMinutesToMs(config.intervalMinutes, `sync "${config.name}" intervalMinutes`);
2088
+ } catch (err) {
2089
+ logger.error(`Timer not started for sync "${config.name}": ${err}`);
2090
+ return;
2091
+ }
2092
+ const timerId = setInterval(() => {
2093
+ this.executeSync(config.id).catch((err) =>
2094
+ logger.error(`Scheduled sync for ${config.name} failed: ${err}`)
2095
+ );
2096
+ }, intervalMs);
2097
+ unrefTimer(timerId);
2098
+ this.timers.set(config.id, timerId);
2099
+ }
2100
+
2101
+ private stopTimer(configId: string): void {
2102
+ const timerId = this.timers.get(configId);
2103
+ if (timerId !== undefined) {
2104
+ clearInterval(timerId);
2105
+ this.timers.delete(configId);
2106
+ }
2107
+ }
2108
+
2109
+ private async waitForRunningSyncs(): Promise<void> {
2110
+ while (this.runningSync.size > 0 || this.activeGitChildren.size > 0) {
2111
+ await new Promise<void>((resolve) => {
2112
+ const timer = setTimeout(resolve, 100);
2113
+ unrefTimer(timer);
2114
+ });
2115
+ }
2116
+ }
2117
+ }