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.
@@ -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; // 'centralized' or 'decentralized'
76
+ this.syncMode = syncMode;
10
77
  this.debug = debug;
11
-
12
- // 确保同步目录存在,否则 simple-git 初始化会报错
13
- if (!fs.existsSync(this.syncDir)) {
14
- fs.mkdirSync(this.syncDir, { recursive: true });
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
- await this.git.addRemote("origin", this.githubRepo);
205
+ }
40
206
 
41
- // 尝试拉取远程仓库以防它是非空的
42
- try {
43
- await this.git.pull("origin", "main", {
44
- "--allow-unrelated-histories": null,
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
- // 获取当前分支,如果为空则尝试设置为 main
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
- if (!currentBranch) {
70
- try {
71
- await this.git.checkoutLocalBranch("main");
72
- } catch (e) {
73
- // ignore
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
- this.log("拉取远程最新状态...");
79
- try {
80
- await this.git.pull("origin", "main");
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.log(`开始监听目录变化: ${this.syncDir}`);
412
+ const watchPaths = this.getWatchPaths();
413
+ this.log(`开始监听目录变化: ${watchPaths.join(", ")}`);
95
414
 
96
- // 监听目录变化,忽略 .git 目录
97
- this.watcher = chokidar.watch(this.syncDir, {
98
- ignored: /(^|[/\\])\../, // ignore dotfiles
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, filepath) => {
108
- this.log(`检测到文件变化 [${event}]: ${filepath}`);
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", (path) => triggerSync("add", path))
117
- .on("change", (path) => triggerSync("change", path))
118
- .on("unlink", (path) => triggerSync("unlink", path));
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
- const status = await this.git.status();
127
- if (status.isClean()) {
128
- this.log("没有需要提交的更改");
129
- this.isSyncing = false;
130
- return;
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
- this.log("正在提交并推送到 GitHub...");
134
- await this.git.add("./*");
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
- const timestamp = new Date().toISOString();
137
- await this.git.commit(`Auto-sync: ${timestamp} via OpenClaw`);
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 {