openclaw-sync-assistant 0.1.4 → 0.1.5
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.
- package/index.js +1197 -64
- package/openclaw.plugin.json +27 -2
- package/package.json +6 -1
- package/src/github-sync.js +607 -70
- package/src/p2p-sync.js +649 -0
- package/src/sync-items.js +103 -0
- package/.eslintrc.json +0 -15
- package/.trae/documents/distributed_sync_plan.md +0 -105
- package/test.js +0 -10
package/src/github-sync.js
CHANGED
|
@@ -1,23 +1,154 @@
|
|
|
1
1
|
const simpleGit = require("simple-git");
|
|
2
2
|
const chokidar = require("chokidar");
|
|
3
3
|
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const { normalizeSyncItems, resolveSyncEntries } = require("./sync-items");
|
|
6
|
+
|
|
7
|
+
const PRESERVED_ENTRY_NAMES = new Set([".git"]);
|
|
8
|
+
|
|
9
|
+
function listDirectoryEntries(rootPath, { excludePreserved = false } = {}) {
|
|
10
|
+
if (!fs.existsSync(rootPath)) {
|
|
11
|
+
return [];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const entryNames = fs.readdirSync(rootPath);
|
|
15
|
+
if (!excludePreserved) {
|
|
16
|
+
return entryNames;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return entryNames.filter(
|
|
20
|
+
(entryName) => !PRESERVED_ENTRY_NAMES.has(entryName),
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function mirrorDirectory(
|
|
25
|
+
sourcePath,
|
|
26
|
+
targetPath,
|
|
27
|
+
{ preserveTargetEntries = false } = {},
|
|
28
|
+
) {
|
|
29
|
+
if (fs.existsSync(targetPath) && !fs.statSync(targetPath).isDirectory()) {
|
|
30
|
+
fs.rmSync(targetPath, { recursive: true, force: true });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
fs.mkdirSync(targetPath, { recursive: true });
|
|
34
|
+
|
|
35
|
+
const sourceEntries = listDirectoryEntries(sourcePath, {
|
|
36
|
+
excludePreserved: true,
|
|
37
|
+
});
|
|
38
|
+
const sourceEntrySet = new Set(sourceEntries);
|
|
39
|
+
const targetEntries = listDirectoryEntries(targetPath, {
|
|
40
|
+
excludePreserved: preserveTargetEntries,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
for (const entryName of targetEntries) {
|
|
44
|
+
if (!sourceEntrySet.has(entryName)) {
|
|
45
|
+
fs.rmSync(path.join(targetPath, entryName), {
|
|
46
|
+
recursive: true,
|
|
47
|
+
force: true,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const entryName of sourceEntries) {
|
|
53
|
+
const sourceEntryPath = path.join(sourcePath, entryName);
|
|
54
|
+
const targetEntryPath = path.join(targetPath, entryName);
|
|
55
|
+
const sourceStat = fs.statSync(sourceEntryPath);
|
|
56
|
+
|
|
57
|
+
if (sourceStat.isDirectory()) {
|
|
58
|
+
mirrorDirectory(sourceEntryPath, targetEntryPath);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
fs.mkdirSync(path.dirname(targetEntryPath), { recursive: true });
|
|
63
|
+
fs.copyFileSync(sourceEntryPath, targetEntryPath);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
4
66
|
|
|
5
67
|
class GitSyncService {
|
|
6
|
-
constructor(syncDir, githubRepo, syncMode, debug = false) {
|
|
68
|
+
constructor(syncDir, githubRepo, syncMode, options = {}, debug = false) {
|
|
69
|
+
if (typeof options === "boolean") {
|
|
70
|
+
debug = options;
|
|
71
|
+
options = {};
|
|
72
|
+
}
|
|
73
|
+
|
|
7
74
|
this.syncDir = syncDir;
|
|
8
75
|
this.githubRepo = githubRepo;
|
|
9
|
-
this.syncMode = syncMode;
|
|
76
|
+
this.syncMode = syncMode;
|
|
10
77
|
this.debug = debug;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
78
|
+
this.openclawDir = options.openclawDir || path.dirname(syncDir);
|
|
79
|
+
this.syncItems = GitSyncService.normalizeSyncItems(options.syncItems);
|
|
80
|
+
this.syncEntries = GitSyncService.resolveSyncEntries(
|
|
81
|
+
this.openclawDir,
|
|
82
|
+
this.syncDir,
|
|
83
|
+
this.syncItems,
|
|
84
|
+
);
|
|
85
|
+
fs.mkdirSync(this.syncDir, { recursive: true });
|
|
17
86
|
this.git = simpleGit(this.syncDir);
|
|
18
87
|
this.watcher = null;
|
|
19
88
|
this.syncTimeout = null;
|
|
20
89
|
this.isSyncing = false;
|
|
90
|
+
this.suspendWatchUntil = 0;
|
|
91
|
+
this.lastSyncAt = null;
|
|
92
|
+
this.lastError = null;
|
|
93
|
+
this.lastConflictFiles = [];
|
|
94
|
+
this.lastConflictAt = null;
|
|
95
|
+
this.primaryBranch = "main";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
static normalizeSyncItems(syncItems) {
|
|
99
|
+
return normalizeSyncItems(syncItems);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
static resolveSyncEntries(openclawDir, syncDir, syncItems) {
|
|
103
|
+
return resolveSyncEntries(openclawDir, syncDir, syncItems);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
static buildConflictFilePath(filePath, timestamp, label = "conflict") {
|
|
107
|
+
return `${filePath}.${label}.${timestamp}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
static getModePolicy(syncMode) {
|
|
111
|
+
if (syncMode === "centralized") {
|
|
112
|
+
return {
|
|
113
|
+
mergeStrategy: "theirs",
|
|
114
|
+
preserveSource: "local",
|
|
115
|
+
conflictLabel: "local-conflict",
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
mergeStrategy: "ours",
|
|
121
|
+
preserveSource: "remote",
|
|
122
|
+
conflictLabel: "conflict",
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
static collectStatusFiles(status) {
|
|
127
|
+
const files = new Set();
|
|
128
|
+
const arrays = [
|
|
129
|
+
status.not_added || [],
|
|
130
|
+
status.created || [],
|
|
131
|
+
status.deleted || [],
|
|
132
|
+
status.modified || [],
|
|
133
|
+
status.renamed || [],
|
|
134
|
+
status.staged || [],
|
|
135
|
+
status.conflicted || [],
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
for (const entries of arrays) {
|
|
139
|
+
for (const entry of entries) {
|
|
140
|
+
if (!entry) continue;
|
|
141
|
+
if (typeof entry === "string") {
|
|
142
|
+
files.add(entry);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (entry.from) files.add(entry.from);
|
|
146
|
+
if (entry.to) files.add(entry.to);
|
|
147
|
+
if (entry.path) files.add(entry.path);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return [...files];
|
|
21
152
|
}
|
|
22
153
|
|
|
23
154
|
log(...args) {
|
|
@@ -27,75 +158,264 @@ class GitSyncService {
|
|
|
27
158
|
}
|
|
28
159
|
|
|
29
160
|
error(...args) {
|
|
161
|
+
this.lastError = args
|
|
162
|
+
.map((item) => (item instanceof Error ? item.message : String(item)))
|
|
163
|
+
.join(" ");
|
|
30
164
|
console.error("[OpenClaw Sync (GitHub)] ❌", ...args);
|
|
31
165
|
}
|
|
32
166
|
|
|
167
|
+
getStatus() {
|
|
168
|
+
return {
|
|
169
|
+
transport: "github",
|
|
170
|
+
mode: this.syncMode,
|
|
171
|
+
repo: this.githubRepo,
|
|
172
|
+
syncDir: this.syncDir,
|
|
173
|
+
openclawDir: this.openclawDir,
|
|
174
|
+
syncItems: [...this.syncItems],
|
|
175
|
+
isSyncing: this.isSyncing,
|
|
176
|
+
lastSyncAt: this.lastSyncAt,
|
|
177
|
+
lastError: this.lastError,
|
|
178
|
+
lastConflictAt: this.lastConflictAt,
|
|
179
|
+
lastConflictFiles: [...this.lastConflictFiles],
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
pathExists(targetPath) {
|
|
184
|
+
return fs.existsSync(targetPath);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
copyEntry(sourcePath, targetPath, options = {}) {
|
|
188
|
+
const sourceStat = fs.statSync(sourcePath);
|
|
189
|
+
if (sourceStat.isDirectory()) {
|
|
190
|
+
mirrorDirectory(sourcePath, targetPath, options);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
fs.rmSync(targetPath, { recursive: true, force: true });
|
|
195
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
196
|
+
fs.copyFileSync(sourcePath, targetPath);
|
|
197
|
+
}
|
|
198
|
+
|
|
33
199
|
async init() {
|
|
34
200
|
try {
|
|
35
201
|
const isRepo = await this.git.checkIsRepo();
|
|
36
202
|
if (!isRepo) {
|
|
37
203
|
this.log("初始化本地 Git 仓库...");
|
|
38
204
|
await this.git.init();
|
|
39
|
-
|
|
205
|
+
}
|
|
40
206
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
});
|
|
46
|
-
} catch (e) {
|
|
47
|
-
this.log("远程仓库可能为空,或者拉取失败 (可忽略):", e.message);
|
|
48
|
-
}
|
|
49
|
-
} else {
|
|
50
|
-
// 检查并更新 remote
|
|
51
|
-
const remotes = await this.git.getRemotes(true);
|
|
52
|
-
const origin = remotes.find((r) => r.name === "origin");
|
|
53
|
-
if (!origin) {
|
|
54
|
-
await this.git.addRemote("origin", this.githubRepo);
|
|
55
|
-
} else if (origin.refs.fetch !== this.githubRepo) {
|
|
56
|
-
await this.git.removeRemote("origin");
|
|
57
|
-
await this.git.addRemote("origin", this.githubRepo);
|
|
58
|
-
}
|
|
207
|
+
await this.ensureGitTransportConfig();
|
|
208
|
+
await this.ensureRemote();
|
|
209
|
+
await this.detectPrimaryBranch();
|
|
210
|
+
await this.ensurePrimaryBranch();
|
|
59
211
|
|
|
60
|
-
|
|
61
|
-
let currentBranch = "";
|
|
62
|
-
try {
|
|
63
|
-
const branches = await this.git.branch();
|
|
64
|
-
currentBranch = branches.current;
|
|
65
|
-
} catch (e) {
|
|
66
|
-
// ignore
|
|
67
|
-
}
|
|
212
|
+
const hasRemoteBranch = await this.fetchRemoteBranch();
|
|
68
213
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
}
|
|
214
|
+
if (hasRemoteBranch) {
|
|
215
|
+
const localHeadExists = await this.localHeadExists();
|
|
216
|
+
const hasSharedHistory = localHeadExists
|
|
217
|
+
? await this.hasSharedHistoryWithRemote()
|
|
218
|
+
: false;
|
|
76
219
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
220
|
+
if (!localHeadExists || !hasSharedHistory) {
|
|
221
|
+
this.log(`建立远端 ${this.primaryBranch} 基线...`);
|
|
222
|
+
await this.alignLocalRepoWithRemoteBranch();
|
|
223
|
+
} else {
|
|
224
|
+
this.log("拉取远程最新状态...");
|
|
225
|
+
await this.git.pull("origin", this.primaryBranch, {
|
|
226
|
+
"--no-rebase": null,
|
|
227
|
+
});
|
|
228
|
+
await this.syncRepoToSources();
|
|
81
229
|
this.log("✅ 拉取成功");
|
|
82
|
-
} catch (e) {
|
|
83
|
-
this.error("拉取失败 (可能仓库为空或无 main 分支):", e.message);
|
|
84
230
|
}
|
|
85
231
|
}
|
|
86
232
|
|
|
233
|
+
await this.syncSourcesToRepo();
|
|
234
|
+
await this.performSync();
|
|
87
235
|
this.startWatching();
|
|
88
236
|
} catch (err) {
|
|
89
237
|
this.error("初始化失败:", err);
|
|
90
238
|
}
|
|
91
239
|
}
|
|
92
240
|
|
|
241
|
+
async ensureRemote() {
|
|
242
|
+
const remotes = await this.git.getRemotes(true);
|
|
243
|
+
const origin = remotes.find((remote) => remote.name === "origin");
|
|
244
|
+
|
|
245
|
+
if (!origin) {
|
|
246
|
+
await this.git.addRemote("origin", this.githubRepo);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (origin.refs.fetch !== this.githubRepo) {
|
|
251
|
+
await this.git.removeRemote("origin");
|
|
252
|
+
await this.git.addRemote("origin", this.githubRepo);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async ensureGitTransportConfig() {
|
|
257
|
+
const entries = [
|
|
258
|
+
["http.version", "HTTP/1.1"],
|
|
259
|
+
["http.lowSpeedLimit", "0"],
|
|
260
|
+
["http.lowSpeedTime", "999999"],
|
|
261
|
+
];
|
|
262
|
+
|
|
263
|
+
for (const [key, value] of entries) {
|
|
264
|
+
try {
|
|
265
|
+
await this.git.addConfig(key, value);
|
|
266
|
+
} catch (error) {
|
|
267
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
268
|
+
if (/could not lock config file|permission denied/i.test(message)) {
|
|
269
|
+
this.log(`跳过本地 Git 配置写入: ${key}`);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
getRemoteBranchRef() {
|
|
278
|
+
return `origin/${this.primaryBranch}`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async detectPrimaryBranch() {
|
|
282
|
+
try {
|
|
283
|
+
const output = await this.git.raw([
|
|
284
|
+
"ls-remote",
|
|
285
|
+
"--symref",
|
|
286
|
+
"origin",
|
|
287
|
+
"HEAD",
|
|
288
|
+
]);
|
|
289
|
+
const match = output.match(/ref:\s+refs\/heads\/([^\s]+)\s+HEAD/);
|
|
290
|
+
if (match && match[1]) {
|
|
291
|
+
this.primaryBranch = match[1];
|
|
292
|
+
return this.primaryBranch;
|
|
293
|
+
}
|
|
294
|
+
} catch (error) {
|
|
295
|
+
void error;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
for (const candidate of ["main", "master"]) {
|
|
299
|
+
try {
|
|
300
|
+
await this.git.raw([
|
|
301
|
+
"ls-remote",
|
|
302
|
+
"--exit-code",
|
|
303
|
+
"--heads",
|
|
304
|
+
"origin",
|
|
305
|
+
candidate,
|
|
306
|
+
]);
|
|
307
|
+
this.primaryBranch = candidate;
|
|
308
|
+
return this.primaryBranch;
|
|
309
|
+
} catch (error) {
|
|
310
|
+
void error;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
this.primaryBranch = "main";
|
|
315
|
+
return this.primaryBranch;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async ensurePrimaryBranch() {
|
|
319
|
+
const primaryBranch = this.primaryBranch;
|
|
320
|
+
const branches = await this.git.branchLocal();
|
|
321
|
+
if (branches.current === primaryBranch) {
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (branches.all.includes(primaryBranch)) {
|
|
326
|
+
await this.git.checkout(primaryBranch);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
await this.git.checkoutLocalBranch(primaryBranch);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async remoteBranchExists() {
|
|
334
|
+
try {
|
|
335
|
+
await this.git.raw(["rev-parse", "--verify", this.getRemoteBranchRef()]);
|
|
336
|
+
return true;
|
|
337
|
+
} catch {
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async localHeadExists() {
|
|
343
|
+
try {
|
|
344
|
+
await this.git.raw(["rev-parse", "--verify", "HEAD"]);
|
|
345
|
+
return true;
|
|
346
|
+
} catch {
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async fetchRemoteBranch() {
|
|
352
|
+
try {
|
|
353
|
+
await this.git.fetch("origin", this.primaryBranch);
|
|
354
|
+
return await this.remoteBranchExists();
|
|
355
|
+
} catch (error) {
|
|
356
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
357
|
+
if (
|
|
358
|
+
new RegExp(`couldn't find remote ref ${this.primaryBranch}`, "i").test(
|
|
359
|
+
message,
|
|
360
|
+
)
|
|
361
|
+
) {
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
throw error;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async hasSharedHistoryWithRemote() {
|
|
369
|
+
if (!(await this.localHeadExists()) || !(await this.remoteBranchExists())) {
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
try {
|
|
374
|
+
const mergeBase = await this.git.raw([
|
|
375
|
+
"merge-base",
|
|
376
|
+
"HEAD",
|
|
377
|
+
this.getRemoteBranchRef(),
|
|
378
|
+
]);
|
|
379
|
+
return Boolean(String(mergeBase).trim());
|
|
380
|
+
} catch {
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async alignLocalRepoWithRemoteBranch() {
|
|
386
|
+
if (!(await this.remoteBranchExists())) {
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (await this.localHeadExists()) {
|
|
391
|
+
await this.git.checkout(this.primaryBranch);
|
|
392
|
+
await this.git.reset(["--hard", this.getRemoteBranchRef()]);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
await this.git.checkout([
|
|
397
|
+
"-B",
|
|
398
|
+
this.primaryBranch,
|
|
399
|
+
this.getRemoteBranchRef(),
|
|
400
|
+
]);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
getWatchPaths() {
|
|
404
|
+
if (this.syncEntries.length > 0) {
|
|
405
|
+
return this.syncEntries.map((entry) => entry.source);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return [this.syncDir];
|
|
409
|
+
}
|
|
410
|
+
|
|
93
411
|
startWatching() {
|
|
94
|
-
this.
|
|
412
|
+
const watchPaths = this.getWatchPaths();
|
|
413
|
+
this.log(`开始监听目录变化: ${watchPaths.join(", ")}`);
|
|
95
414
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
415
|
+
this.watcher = chokidar.watch(watchPaths, {
|
|
416
|
+
ignored: (targetPath) =>
|
|
417
|
+
targetPath.includes(`${path.sep}.git${path.sep}`) ||
|
|
418
|
+
path.basename(targetPath) === ".git",
|
|
99
419
|
persistent: true,
|
|
100
420
|
ignoreInitial: true,
|
|
101
421
|
awaitWriteFinish: {
|
|
@@ -104,18 +424,154 @@ class GitSyncService {
|
|
|
104
424
|
},
|
|
105
425
|
});
|
|
106
426
|
|
|
107
|
-
const triggerSync = (event,
|
|
108
|
-
|
|
427
|
+
const triggerSync = (event, filePath) => {
|
|
428
|
+
if (Date.now() < this.suspendWatchUntil) {
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
this.log(`检测到文件变化 [${event}]: ${filePath}`);
|
|
109
433
|
|
|
110
|
-
// 防抖:2秒内没有新变化则触发同步
|
|
111
434
|
if (this.syncTimeout) clearTimeout(this.syncTimeout);
|
|
112
435
|
this.syncTimeout = setTimeout(() => this.performSync(), 2000);
|
|
113
436
|
};
|
|
114
437
|
|
|
115
438
|
this.watcher
|
|
116
|
-
.on("add", (
|
|
117
|
-
.on("change", (
|
|
118
|
-
.on("unlink", (
|
|
439
|
+
.on("add", (filePath) => triggerSync("add", filePath))
|
|
440
|
+
.on("change", (filePath) => triggerSync("change", filePath))
|
|
441
|
+
.on("unlink", (filePath) => triggerSync("unlink", filePath))
|
|
442
|
+
.on("addDir", (filePath) => triggerSync("addDir", filePath))
|
|
443
|
+
.on("unlinkDir", (filePath) => triggerSync("unlinkDir", filePath));
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async syncSourcesToRepo() {
|
|
447
|
+
if (this.syncEntries.length === 0) {
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
for (const entry of this.syncEntries) {
|
|
452
|
+
if (!this.pathExists(entry.source)) {
|
|
453
|
+
fs.rmSync(entry.target, { recursive: true, force: true });
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
this.copyEntry(entry.source, entry.target);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async syncRepoToSources() {
|
|
462
|
+
if (this.syncEntries.length === 0) {
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
this.suspendWatchUntil = Date.now() + 4000;
|
|
467
|
+
|
|
468
|
+
for (const entry of this.syncEntries) {
|
|
469
|
+
if (!this.pathExists(entry.target)) {
|
|
470
|
+
fs.rmSync(entry.source, { recursive: true, force: true });
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
this.copyEntry(entry.target, entry.source, {
|
|
475
|
+
preserveTargetEntries: true,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async getAheadBehind() {
|
|
481
|
+
const output = await this.git.raw([
|
|
482
|
+
"rev-list",
|
|
483
|
+
"--left-right",
|
|
484
|
+
"--count",
|
|
485
|
+
`HEAD...${this.getRemoteBranchRef()}`,
|
|
486
|
+
]);
|
|
487
|
+
const [aheadText = "0", behindText = "0"] = output.trim().split(/\s+/);
|
|
488
|
+
|
|
489
|
+
return {
|
|
490
|
+
ahead: Number.parseInt(aheadText, 10) || 0,
|
|
491
|
+
behind: Number.parseInt(behindText, 10) || 0,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async listChangedFiles(fromRef, toRef) {
|
|
496
|
+
const output = await this.git.raw([
|
|
497
|
+
"diff",
|
|
498
|
+
"--name-only",
|
|
499
|
+
`${fromRef}..${toRef}`,
|
|
500
|
+
]);
|
|
501
|
+
|
|
502
|
+
return output
|
|
503
|
+
.split(/\r?\n/)
|
|
504
|
+
.map((filePath) => filePath.trim())
|
|
505
|
+
.filter(Boolean);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async saveRemoteConflictFiles(filePaths, timestamp, label = "conflict") {
|
|
509
|
+
const writtenFiles = [];
|
|
510
|
+
|
|
511
|
+
for (const filePath of filePaths) {
|
|
512
|
+
try {
|
|
513
|
+
const remoteContent = await this.git.raw([
|
|
514
|
+
"show",
|
|
515
|
+
`${this.getRemoteBranchRef()}:${filePath.replace(/\\/g, "/")}`,
|
|
516
|
+
]);
|
|
517
|
+
const conflictPath = path.join(
|
|
518
|
+
this.syncDir,
|
|
519
|
+
GitSyncService.buildConflictFilePath(filePath, timestamp, label),
|
|
520
|
+
);
|
|
521
|
+
|
|
522
|
+
fs.mkdirSync(path.dirname(conflictPath), { recursive: true });
|
|
523
|
+
fs.writeFileSync(conflictPath, remoteContent);
|
|
524
|
+
writtenFiles.push(
|
|
525
|
+
GitSyncService.buildConflictFilePath(filePath, timestamp, label),
|
|
526
|
+
);
|
|
527
|
+
} catch {
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
return writtenFiles;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
async saveLocalConflictFiles(filePaths, timestamp, label = "local-conflict") {
|
|
536
|
+
const writtenFiles = [];
|
|
537
|
+
|
|
538
|
+
for (const filePath of filePaths) {
|
|
539
|
+
const sourcePath = path.join(this.syncDir, filePath);
|
|
540
|
+
|
|
541
|
+
if (!this.pathExists(sourcePath)) {
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const sourceStat = fs.statSync(sourcePath);
|
|
546
|
+
if (!sourceStat.isFile()) {
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const conflictPath = path.join(
|
|
551
|
+
this.syncDir,
|
|
552
|
+
GitSyncService.buildConflictFilePath(filePath, timestamp, label),
|
|
553
|
+
);
|
|
554
|
+
|
|
555
|
+
fs.mkdirSync(path.dirname(conflictPath), { recursive: true });
|
|
556
|
+
fs.copyFileSync(sourcePath, conflictPath);
|
|
557
|
+
writtenFiles.push(
|
|
558
|
+
GitSyncService.buildConflictFilePath(filePath, timestamp, label),
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return writtenFiles;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
async commitAllChanges(message) {
|
|
566
|
+
await this.git.add(".");
|
|
567
|
+
const status = await this.git.status();
|
|
568
|
+
|
|
569
|
+
if (status.isClean()) {
|
|
570
|
+
return false;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
await this.git.commit(message);
|
|
574
|
+
return true;
|
|
119
575
|
}
|
|
120
576
|
|
|
121
577
|
async performSync() {
|
|
@@ -123,22 +579,103 @@ class GitSyncService {
|
|
|
123
579
|
this.isSyncing = true;
|
|
124
580
|
|
|
125
581
|
try {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
582
|
+
this.lastError = null;
|
|
583
|
+
await this.detectPrimaryBranch();
|
|
584
|
+
await this.ensurePrimaryBranch();
|
|
585
|
+
const hasRemoteBranch = await this.fetchRemoteBranch();
|
|
586
|
+
|
|
587
|
+
if (hasRemoteBranch && !(await this.hasSharedHistoryWithRemote())) {
|
|
588
|
+
this.log(
|
|
589
|
+
`检测到本地同步仓库与远端 ${this.primaryBranch} 分叉,重新对齐远端基线...`,
|
|
590
|
+
);
|
|
591
|
+
await this.alignLocalRepoWithRemoteBranch();
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
await this.syncSourcesToRepo();
|
|
595
|
+
|
|
596
|
+
const timestamp = new Date().toISOString().replace(/[:]/g, "-");
|
|
597
|
+
const modePolicy = GitSyncService.getModePolicy(this.syncMode);
|
|
598
|
+
const initialStatus = await this.git.status();
|
|
599
|
+
const localChangedFiles =
|
|
600
|
+
GitSyncService.collectStatusFiles(initialStatus);
|
|
601
|
+
const hadLocalChanges = !initialStatus.isClean();
|
|
602
|
+
|
|
603
|
+
if (hadLocalChanges) {
|
|
604
|
+
this.log("检测到本地更改,准备提交...");
|
|
605
|
+
await this.commitAllChanges(`Auto-sync: ${timestamp} via OpenClaw`);
|
|
131
606
|
}
|
|
132
607
|
|
|
133
|
-
|
|
134
|
-
|
|
608
|
+
if (hasRemoteBranch) {
|
|
609
|
+
const { behind } = await this.getAheadBehind();
|
|
610
|
+
|
|
611
|
+
if (behind > 0) {
|
|
612
|
+
const remoteChangedFiles = await this.listChangedFiles(
|
|
613
|
+
"HEAD",
|
|
614
|
+
this.getRemoteBranchRef(),
|
|
615
|
+
);
|
|
616
|
+
const conflictCandidates = remoteChangedFiles.filter((filePath) =>
|
|
617
|
+
localChangedFiles.includes(filePath),
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
if (
|
|
621
|
+
modePolicy.preserveSource === "local" &&
|
|
622
|
+
conflictCandidates.length > 0
|
|
623
|
+
) {
|
|
624
|
+
this.lastConflictFiles = await this.saveLocalConflictFiles(
|
|
625
|
+
conflictCandidates,
|
|
626
|
+
timestamp,
|
|
627
|
+
modePolicy.conflictLabel,
|
|
628
|
+
);
|
|
629
|
+
this.lastConflictAt =
|
|
630
|
+
this.lastConflictFiles.length > 0
|
|
631
|
+
? new Date().toISOString()
|
|
632
|
+
: null;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
await this.git.merge([
|
|
636
|
+
"-X",
|
|
637
|
+
modePolicy.mergeStrategy,
|
|
638
|
+
this.getRemoteBranchRef(),
|
|
639
|
+
]);
|
|
135
640
|
|
|
136
|
-
|
|
137
|
-
|
|
641
|
+
if (
|
|
642
|
+
modePolicy.preserveSource === "remote" &&
|
|
643
|
+
conflictCandidates.length > 0
|
|
644
|
+
) {
|
|
645
|
+
this.lastConflictFiles = await this.saveRemoteConflictFiles(
|
|
646
|
+
conflictCandidates,
|
|
647
|
+
timestamp,
|
|
648
|
+
modePolicy.conflictLabel,
|
|
649
|
+
);
|
|
650
|
+
|
|
651
|
+
this.lastConflictAt =
|
|
652
|
+
this.lastConflictFiles.length > 0
|
|
653
|
+
? new Date().toISOString()
|
|
654
|
+
: null;
|
|
655
|
+
|
|
656
|
+
if (this.lastConflictFiles.length > 0) {
|
|
657
|
+
await this.commitAllChanges(
|
|
658
|
+
`Preserve remote conflicts: ${timestamp}`,
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
await this.syncRepoToSources();
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const committed = await this.commitAllChanges(
|
|
668
|
+
`Auto-sync: ${timestamp} via OpenClaw`,
|
|
669
|
+
);
|
|
670
|
+
|
|
671
|
+
if (committed || hadLocalChanges || (await this.remoteBranchExists())) {
|
|
672
|
+
await this.git.push("origin", this.primaryBranch);
|
|
673
|
+
this.log("✅ 同步推送成功!");
|
|
674
|
+
} else {
|
|
675
|
+
this.log("没有需要提交的更改");
|
|
676
|
+
}
|
|
138
677
|
|
|
139
|
-
|
|
140
|
-
await this.git.push("origin", "main");
|
|
141
|
-
this.log("✅ 同步推送成功!");
|
|
678
|
+
this.lastSyncAt = new Date().toISOString();
|
|
142
679
|
} catch (err) {
|
|
143
680
|
this.error("同步推送失败:", err);
|
|
144
681
|
} finally {
|