@charara97/dsh-session-sync 0.0.0-stage → 1.0.0

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/lib/engine.js ADDED
@@ -0,0 +1,2613 @@
1
+ /**
2
+ * 同步引擎。
3
+ *
4
+ * 设计取舍(相对旧的 dsh-chatsync):
5
+ * 1) 只有显式的 push / pull,没有后台自动同步,没有启动/退出钩子 —— 用户按一下才动。
6
+ * 2) 范围可选「单个会话」或「全部会话」;工作区文件是同一个操作里的一个开关。
7
+ * 3) 过程全程写进 ProgressLog,浏览器增量拉取,绝不只有一句结果。
8
+ * 4) 冲突不自动裁决、不静默丢弃:
9
+ * - 会话日志只有在「严格追加」时才自动合并(本质无冲突);
10
+ * - 其他文本文件先让 git 做三路合并(非重叠改动自动合上);
11
+ * - 真冲突一律记录下来、把远端版本单独存一份、停在原地等用户选,
12
+ * pull 会 `git merge --abort` 保证工作树停在操作前的状态。
13
+ *
14
+ * 存储布局与旧插件保持一致,以便复用同一个 GitHub 仓库与已有的同步数据:
15
+ * - 主数据 = DSH HOME 本身作为一个 git 仓库,分支默认 main;
16
+ * - 每个工作区一个影子仓库 <HOME>/workspace-repos/<workspaceId>.git,
17
+ * core.worktree 指向真实项目目录,推送到同一个远端的 ws/<workspaceId> 分支。
18
+ */
19
+ import fs from 'node:fs';
20
+ import os from 'node:os';
21
+ import path from 'node:path';
22
+ import { randomUUID } from 'node:crypto';
23
+ import {
24
+ run, humanSize, ts, normRel, zstdDecode, ZSTD_MAGIC, clip,
25
+ } from './util.js';
26
+ import {
27
+ listLocalSessions, readSessionTitle, decodeSessionLog, mergeSessionLog,
28
+ duplicateSessionLog, newSessionId, projCachePath, SESSION_LOG_RE,
29
+ } from './session.js';
30
+ import { RunState, ProgressLog } from './progress.js';
31
+
32
+ export const DEFAULT_CONFIG = {
33
+ remote: '',
34
+ branch: 'main',
35
+ proxy: '',
36
+ autoRepo: true,
37
+ repoName: 'dsh-sync',
38
+ repoOwner: '',
39
+ repoDescription: '',
40
+ gitUserName: 'dsh-chat-sync',
41
+ gitUserEmail: 'dsh-chat-sync@localhost',
42
+ commitMessage: 'dsh-chat-sync',
43
+ workspaceBranchPrefix: 'ws',
44
+ workspaceExtraIgnore: ['node_modules', '.pnpm-store', '.npm-cache', '.venv', 'dist', 'build'],
45
+ /** 是否在侧栏左下角显示「会话同步」按钮(纯界面偏好,存在 chat-sync.json 里) */
46
+ showSidebarButton: true,
47
+ /** 是否在工作区根目录自动创建 .dsh-chat-sync-ignore(不存在时才创建) */
48
+ createWorkspaceIgnoreFile: true,
49
+ extraIgnore: [],
50
+ /** 从旧插件的 dsh-sync.json 继承的连接类字段(只读,不会写回那个文件) */
51
+ inheritLegacyConfig: true,
52
+ };
53
+
54
+ /** 主仓库 .gitignore:只排除「每台机器各自的状态 / 可再生依赖 / 密钥」 */
55
+ export const BUILTIN_IGNORE = [
56
+ '# ===== dsh-chat-sync 自动生成(可手工修改;删除后将于下次操作时重建)=====',
57
+ '',
58
+ '# 机器本地状态 —— 各机器不同,无需同步',
59
+ '.anonymous-user-id',
60
+ '.dshw-size.json',
61
+ '.dshw-usage.json',
62
+ '',
63
+ '# 可再生状态 —— 每次运行均会重写,同步只会产生冲突',
64
+ 'cache/',
65
+ 'logs/',
66
+ 'profiles/web/.dsh-market/',
67
+ 'profiles/desktop/.dsh-market/',
68
+ 'profiles/desktop/.plugin-manager/',
69
+ '**/*.log',
70
+ '',
71
+ '# 密钥 —— 不上传(跨机器密钥请使用环境变量)',
72
+ '.credentials.yaml',
73
+ '',
74
+ '# 会话投影缓存目录参与同步(标题 / cwd 用于还原「工作区 ↔ 会话」归属);',
75
+ '# 仅排除同级聚合文件,目录本身保留',
76
+ 'storages/session_projcache.json',
77
+ '',
78
+ '# 依赖目录 / pnpm 存储 —— 新机器需各自安装',
79
+ '**/node_modules/',
80
+ '.pnpm-store/',
81
+ '',
82
+ '# 同步引擎自身状态',
83
+ '.chat-sync/',
84
+ '.dsh-sync.state.json',
85
+ '.dsh-sync.process.lock',
86
+ '',
87
+ '# 工作区路径覆盖(「换位置」为本机专属设置)',
88
+ 'storages/workspace-local-paths.json',
89
+ '',
90
+ '# 工作区影子仓库(独立仓库,不与主仓库混同)',
91
+ 'workspace-repos/',
92
+ '**/session.lock',
93
+ 'storages/*.bak-*',
94
+ 'sessions-archive-*/',
95
+ ].join('\n');
96
+
97
+ /** 工作区里用户自己编辑的忽略规则文件名(第一个是新建时用的) */
98
+ export const WORKSPACE_IGNORE_NAMES = ['.dsh-chat-sync-ignore', '.dsh-sync-ignore'];
99
+
100
+ /** 新建忽略文件时写进去的说明(用户随时可以改) */
101
+ export const WORKSPACE_IGNORE_TEMPLATE = [
102
+ '# dsh-chat-sync:此工作区在本机的同步忽略规则(每行一条,gitignore 语法)',
103
+ '#',
104
+ '# 本文件不参与同步,属于本机专属设置,各机器可不同。',
105
+ '# 规则同时作用于推送与取回两个方向。',
106
+ '#',
107
+ '# 示例:',
108
+ '# dist/ 整个目录不同步',
109
+ '# *.log 所有 .log 文件不同步',
110
+ '# notes/secret.md 单个文件不同步',
111
+ '# !keep.log 例外:仍参与同步(须置于忽略规则之后)',
112
+ '#',
113
+ '# 说明:项目自身的 .gitignore 已直接生效(git 会读取工作树中的 .gitignore);',
114
+ '# 本文件用于声明「本机不同步、但项目仍需保留」的内容。',
115
+ '',
116
+ ].join('\n');
117
+
118
+ /** 迁移过来的 .gitignore 里必须存在的排除项:同步引擎自己的状态目录 */
119
+ const REQUIRED_IGNORE = ['.chat-sync/', 'workspace-repos/'];
120
+
121
+ const MACHINE_LOCAL_TRACKED = [
122
+ '.credentials.yaml', '.dshw-usage.json', '.dshw-size.json', '.anonymous-user-id',
123
+ '.pnpm-store', 'storages/workspace-local-paths.json', 'cache', 'logs',
124
+ 'profiles/web/.dsh-market', 'profiles/desktop/.dsh-market', 'profiles/desktop/.plugin-manager',
125
+ '.chat-sync', 'workspace-repos',
126
+ ];
127
+
128
+ const SESSION_LOG_PATH_RE = /^sessions\/[^/]+\/([^/]+)\/(session(?:\.v\d+)?\.jsonl(?:\.zstd)?)$/i;
129
+
130
+ function nowStamp() {
131
+ return ts();
132
+ }
133
+
134
+ export class SyncEngine {
135
+ constructor(home, overrides = {}, log = () => {}) {
136
+ this.home = home;
137
+ this.overrides = overrides;
138
+ this.log = log;
139
+ this.gitMissing = false;
140
+ this._ghChecked = undefined;
141
+ this._headerCache = new Map();
142
+ this._sessionCache = null;
143
+ this._cfg = null;
144
+ this.currentRun = null;
145
+ this.inFlight = null;
146
+ this.lastRun = null;
147
+ this._bootstrapAt = 0;
148
+ this._bootstrapError = null;
149
+ }
150
+
151
+ /* ================= 配置 ================= */
152
+
153
+ configPath() { return path.join(this.home, 'chat-sync.json'); }
154
+ legacyConfigPath() { return path.join(this.home, 'dsh-sync.json'); }
155
+
156
+ /**
157
+ * 配置优先级:默认值 < 旧插件 dsh-sync.json 的连接字段 < 本插件 chat-sync.json < 插件加载参数。
158
+ * 读旧文件只是为了让已有的 remote/代理等设置能平滑延续,绝不写回旧文件。
159
+ */
160
+ loadConfig() {
161
+ const cfg = { ...DEFAULT_CONFIG };
162
+ if (DEFAULT_CONFIG.inheritLegacyConfig) {
163
+ const legacy = readJson(this.legacyConfigPath());
164
+ if (legacy && typeof legacy === 'object') {
165
+ for (const k of ['remote', 'branch', 'proxy', 'gitUserName', 'gitUserEmail', 'repoName', 'repoOwner', 'repoDescription', 'workspaceBranchPrefix', 'autoRepo', 'extraIgnore', 'workspaceExtraIgnore']) {
166
+ if (legacy[k] !== undefined) cfg[k] = legacy[k];
167
+ }
168
+ }
169
+ }
170
+ const own = readJson(this.configPath());
171
+ if (own && typeof own === 'object') Object.assign(cfg, own);
172
+ Object.assign(cfg, this.overrides || {});
173
+ return cfg;
174
+ }
175
+
176
+ writeConfig(patch) {
177
+ const cur = readJson(this.configPath()) || {};
178
+ const next = { ...cur, ...patch };
179
+ fs.mkdirSync(path.dirname(this.configPath()), { recursive: true });
180
+ fs.writeFileSync(this.configPath(), JSON.stringify(next, null, 2) + '\n', 'utf8');
181
+ this._cfg = null;
182
+ return next;
183
+ }
184
+
185
+ /* ================= 子进程 ================= */
186
+
187
+ gitEnv(cfg = this._cfg || this.loadConfig()) {
188
+ const env = { ...process.env };
189
+ for (const k of ['HTTP_PROXY', 'http_proxy', 'HTTPS_PROXY', 'https_proxy', 'ALL_PROXY', 'all_proxy']) delete env[k];
190
+ const proxy = String(cfg.proxy || '').trim();
191
+ if (proxy) {
192
+ for (const k of ['HTTP_PROXY', 'http_proxy', 'HTTPS_PROXY', 'https_proxy', 'ALL_PROXY', 'all_proxy']) env[k] = proxy;
193
+ env.NO_PROXY = env.NO_PROXY || '127.0.0.1,localhost';
194
+ }
195
+ // 远端要凭据而本机没有/过期时,立刻失败并给出明确报错,而不是弹凭据窗口卡住
196
+ env.GIT_TERMINAL_PROMPT = '0';
197
+ env.GIT_CREDENTIAL_NONINTERACTIVE = '1';
198
+ env.GCM_INTERACTIVE = 'never';
199
+ return env;
200
+ }
201
+
202
+ async git(args, { cwd = null, timeoutMs = 180000, binary = false, onStderr = null, stdin = null, env = null } = {}) {
203
+ const r = await run('git', args, {
204
+ cwd: cwd || this.home,
205
+ env: env ? { ...this.gitEnv(), ...env } : this.gitEnv(),
206
+ timeoutMs,
207
+ binary,
208
+ onStderr,
209
+ stdin,
210
+ });
211
+ if (r.missing) this.gitMissing = true;
212
+ return r;
213
+ }
214
+
215
+ /**
216
+ * 工作区影子仓库上的 git(--git-dir + --work-tree)。
217
+ * cwd 必须设成工作树本身:否则相对 pathspec(`add -- <文件>`、`checkout --ours -- <文件>`)
218
+ * 会以 DSH HOME 为基准解析,落到仓库外面而静默失败。
219
+ */
220
+ gitW(gitdir, worktree, args, opts = {}) {
221
+ return this.git(['--git-dir', gitdir, '--work-tree', worktree, ...args], { ...opts, cwd: worktree });
222
+ }
223
+
224
+ async gh(args, timeoutMs = 90000) {
225
+ return run('gh', args, { cwd: this.home, timeoutMs });
226
+ }
227
+
228
+ async ghAvailable() {
229
+ if (this._ghChecked === undefined) this._ghChecked = (await this.gh(['--version'])).code === 0;
230
+ return this._ghChecked;
231
+ }
232
+
233
+ ok(r) { return r && r.code === 0; }
234
+
235
+ /* ================= 仓库准备 ================= */
236
+
237
+ async ensureRepo(cfg) {
238
+ const gitDir = path.join(this.home, '.git');
239
+ if (!fs.existsSync(gitDir)) {
240
+ const r = await this.git(['init', '-b', cfg.branch || 'main']);
241
+ if (!this.ok(r)) return { ok: false, error: `git init 失败:${clip(r.err || r.code)}` };
242
+ }
243
+ if (cfg.remote) {
244
+ const cur = await this.git(['remote', 'get-url', 'origin']);
245
+ if (!this.ok(cur)) await this.git(['remote', 'add', 'origin', cfg.remote]);
246
+ else if (cur.out.trim().replace(/\/+$/, '') !== String(cfg.remote).replace(/\/+$/, '')) {
247
+ await this.git(['remote', 'set-url', 'origin', cfg.remote]);
248
+ }
249
+ // 只跟随主分支:默认 refspec 会把 ws/* 工作区分支也拉进主仓库
250
+ await this.git(['config', 'remote.origin.fetch', `+refs/heads/${cfg.branch}:refs/remotes/origin/${cfg.branch}`]);
251
+ if (await this.ghAvailable()) await this.git(['config', 'credential.helper', '!gh auth git-credential']);
252
+ }
253
+ await this.git(['config', 'user.name', cfg.gitUserName]);
254
+ await this.git(['config', 'user.email', cfg.gitUserEmail]);
255
+ await this.git(['config', 'core.autocrlf', 'false']);
256
+ await this.git(['config', 'core.safecrlf', 'false']);
257
+ await this.git(['config', 'commit.gpgsign', 'false']);
258
+ // 会话日志绝不能交给 git 的行级合并:seq/turn/step 是格式自身的关系,混排会毁掉日志。
259
+ // 这里写进 .git/info/attributes(本机文件,不随仓库同步)。
260
+ try {
261
+ const attrs = await this.git(['rev-parse', '--git-path', 'info/attributes']);
262
+ if (this.ok(attrs)) {
263
+ const p = path.resolve(this.home, attrs.out.trim());
264
+ const rule = 'sessions/**/session*.jsonl* -merge';
265
+ const cur = fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : '';
266
+ if (!cur.split('\n').some((l) => l.trim() === rule)) {
267
+ fs.mkdirSync(path.dirname(p), { recursive: true });
268
+ fs.writeFileSync(p, cur.replace(/\s*$/, '') + '\n' + rule + '\n', 'utf8');
269
+ }
270
+ }
271
+ } catch { /* 属性写失败不阻断同步 */ }
272
+ // .gitignore:不存在就生成;已存在(例如从旧插件迁移过来)只补上本插件必须排除的条目,
273
+ // 绝不整份重写 —— 用户手工加过的规则必须留着。
274
+ const ignorePath = path.join(this.home, '.gitignore');
275
+ if (!fs.existsSync(ignorePath)) {
276
+ fs.writeFileSync(ignorePath, BUILTIN_IGNORE + '\n' + (cfg.extraIgnore || []).join('\n') + '\n', 'utf8');
277
+ } else {
278
+ try {
279
+ const cur = fs.readFileSync(ignorePath, 'utf8');
280
+ const missing = REQUIRED_IGNORE.filter((t) => !cur.split('\n').some((l) => l.trim() === t));
281
+ if (missing.length) {
282
+ fs.writeFileSync(ignorePath, cur.replace(/\s*$/, '\n') + '\n# 由 dsh-chat-sync 补上(同步引擎自身状态)\n' + missing.join('\n') + '\n', 'utf8');
283
+ }
284
+ } catch { /* ignore 写不进去不阻断 */ }
285
+ }
286
+ // 把混进索引的机器本地文件移出跟踪(不删文件)
287
+ try {
288
+ const ls = await this.git(['ls-files', '--', ...MACHINE_LOCAL_TRACKED]);
289
+ if (this.ok(ls) && ls.out.trim()) {
290
+ await this.git(['rm', '-r', '--cached', '--quiet', '--ignore-unmatch', '--', ...MACHINE_LOCAL_TRACKED]);
291
+ }
292
+ } catch { /* ignore */ }
293
+ return { ok: true };
294
+ }
295
+
296
+ /**
297
+ * remote 为空时用 gh 查找/创建私有仓库。
298
+ * 只复用**已确认私有**的仓库;确认不了就放弃(退化为本地快照),绝不上传。
299
+ */
300
+ async bootstrapRemote(cfg) {
301
+ if (cfg.autoRepo === false) return { remote: null, error: '已关闭自动建仓(autoRepo=false),请先填写远端仓库地址。' };
302
+ if (this._bootstrapAt && Date.now() - this._bootstrapAt < 60000) return { remote: null, error: this._bootstrapError || '刚刚已尝试自动建仓,请稍后重试。' };
303
+ this._bootstrapAt = Date.now();
304
+ this._bootstrapError = null;
305
+ if (!(await this.ghAvailable())) {
306
+ this._bootstrapError = '本机没有可用的 gh(GitHub CLI)。请安装并执行 gh auth login,或在设置里手工填写私有仓库地址。';
307
+ return { remote: null, error: this._bootstrapError };
308
+ }
309
+ const login = await this.gh(['api', 'user', '--jq', '.login']);
310
+ const owner = cfg.repoOwner || (this.ok(login) ? login.out.trim() : '');
311
+ if (!owner) {
312
+ this._bootstrapError = '无法通过 gh 取到 GitHub 账号,请先 gh auth login。';
313
+ return { remote: null, error: this._bootstrapError };
314
+ }
315
+ const repo = cfg.repoName || 'dsh-sync';
316
+ const remote = `https://github.com/${owner}/${repo}.git`;
317
+ const view = await this.gh(['repo', 'view', `${owner}/${repo}`, '--json', 'visibility']);
318
+ if (this.ok(view)) {
319
+ try {
320
+ if (JSON.parse(view.out).visibility !== 'PRIVATE') {
321
+ this._bootstrapError = `${owner}/${repo} 不是私有仓库,本次不会上传。请改用私有仓库。`;
322
+ return { remote: null, error: this._bootstrapError };
323
+ }
324
+ } catch {
325
+ this._bootstrapError = `无法确认 ${owner}/${repo} 的可见性,本次不会上传。`;
326
+ return { remote: null, error: this._bootstrapError };
327
+ }
328
+ this.writeConfig({ remote });
329
+ return { remote, reused: true };
330
+ }
331
+ const args = ['repo', 'create', `${owner}/${repo}`, '--private'];
332
+ if (cfg.repoDescription) args.push('--description', cfg.repoDescription);
333
+ const created = await this.gh(args);
334
+ if (!this.ok(created)) {
335
+ this._bootstrapError = `自动创建私有仓库失败:${clip(created.err || created.code)}。可在 GitHub 手工建仓后填写地址。`;
336
+ return { remote: null, error: this._bootstrapError };
337
+ }
338
+ this.writeConfig({ remote });
339
+ return { remote, created: true };
340
+ }
341
+
342
+ /** 上传前确认远端是私有仓库;gh 不可用时只告警(用户是显式配置的地址)。 */
343
+ async guardPrivate(run, remote) {
344
+ const m = /^(?:https:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/i.exec(String(remote || '').trim());
345
+ if (!m) {
346
+ run.progress.warn('guard', '远端非 github.com 地址,跳过私有性校验(仅支持 GitHub 托管)。');
347
+ return true;
348
+ }
349
+ if (!(await this.ghAvailable())) {
350
+ run.progress.warn('guard', '本机无 gh,已跳过私有性校验;请自行确认仓库为私有。');
351
+ return true;
352
+ }
353
+ const r = await this.gh(['repo', 'view', `${m[1]}/${m[2]}`, '--json', 'visibility']);
354
+ if (!this.ok(r)) {
355
+ run.progress.warn('guard', `无法确认 ${m[1]}/${m[2]} 的可见性,继续执行(gh: ${clip(r.err || r.code)})。`);
356
+ return true;
357
+ }
358
+ let vis = '';
359
+ try { vis = JSON.parse(r.out).visibility; } catch { /* fallthrough */ }
360
+ if (vis !== 'PRIVATE') {
361
+ run.progress.error('guard', `${m[1]}/${m[2]} 不是私有仓库,已取消上传。`);
362
+ return false;
363
+ }
364
+ run.progress.info('guard', `已确认远端 ${m[1]}/${m[2]} 为私有仓库。`);
365
+ return true;
366
+ }
367
+
368
+ /* ================= 扫描 ================= */
369
+
370
+ /** 工作区登记表(storages/workspace.json) */
371
+ workspaceRecords() {
372
+ const j = readJson(path.join(this.home, 'storages', 'workspace.json'));
373
+ const tables = j?.tables?.workspaces ?? {};
374
+ const homeNorm = path.resolve(this.home);
375
+ const out = [];
376
+ for (const [id, w] of Object.entries(tables)) {
377
+ if (!w || typeof w.path !== 'string' || !w.path) continue;
378
+ if (path.resolve(w.path) === homeNorm) continue; // .dsh 自己不做工作区同步
379
+ out.push({
380
+ id,
381
+ path: w.path,
382
+ title: typeof w.title === 'string' && w.title ? w.title : path.basename(w.path) || id,
383
+ sessionIds: Array.isArray(w.sessionIds) ? w.sessionIds.slice() : [],
384
+ });
385
+ }
386
+ out.sort((a, b) => a.title.localeCompare(b.title, 'zh-Hans-CN'));
387
+ return out;
388
+ }
389
+
390
+ sessionToWorkspace() {
391
+ const map = new Map();
392
+ for (const w of this.workspaceRecords()) for (const sid of w.sessionIds) map.set(sid, w.id);
393
+ return map;
394
+ }
395
+
396
+ /** 本机会话(带标题、归属工作区、文件路径) */
397
+ localSessions() {
398
+ const list = listLocalSessions(this.home, this._headerCache);
399
+ const owner = this.sessionToWorkspace();
400
+ for (const s of list) {
401
+ s.title = readSessionTitle(this.home, s.id);
402
+ s.workspaceId = owner.get(s.id) || null;
403
+ }
404
+ list.sort((a, b) => b.mtime - a.mtime);
405
+ return list;
406
+ }
407
+
408
+ /** 远端分支上的文件树:relPath → blob sha */
409
+ async remoteTree(branch) {
410
+ const ref = `refs/remotes/origin/${branch}`;
411
+ const head = await this.git(['rev-parse', '--verify', ref]);
412
+ if (!this.ok(head)) return { exists: false, head: null, files: new Map() };
413
+ const r = await this.git(['ls-tree', '-r', '-z', ref]);
414
+ const files = new Map();
415
+ if (this.ok(r)) {
416
+ for (const rec of r.out.split('\0')) {
417
+ if (!rec) continue;
418
+ const tab = rec.indexOf('\t');
419
+ if (tab < 0) continue;
420
+ const meta = rec.slice(0, tab).split(' ');
421
+ files.set(normRel(rec.slice(tab + 1)), meta[2]);
422
+ }
423
+ }
424
+ return { exists: true, head: head.out.trim(), files };
425
+ }
426
+
427
+ /** 本地文件的内容 sha(与 remoteTree 的 sha 可直接比较) */
428
+ async localBlobShas(relPaths) {
429
+ const out = new Map();
430
+ const list = relPaths.filter((p) => p && fs.existsSync(path.join(this.home, p)));
431
+ if (!list.length) return out;
432
+ // git hash-object 支持一次传多个文件,输出与输入同序
433
+ const CHUNK = 200;
434
+ for (let i = 0; i < list.length; i += CHUNK) {
435
+ const slice = list.slice(i, i + CHUNK);
436
+ const r = await this.git(['hash-object', '--', ...slice], { timeoutMs: 60000 });
437
+ if (!this.ok(r)) continue;
438
+ const shas = r.out.trim().split('\n');
439
+ slice.forEach((p, idx) => { if (shas[idx]) out.set(p, shas[idx].trim()); });
440
+ }
441
+ return out;
442
+ }
443
+
444
+ /* ================= 工作区影子仓库 ================= */
445
+
446
+ workspaceGitDir(id) { return path.join(this.home, 'workspace-repos', id + '.git'); }
447
+ workspaceBranch(cfg, id) { return `${cfg.workspaceBranchPrefix || 'ws'}/${id}`; }
448
+
449
+ /** 一个仓库对应的远端分支名:主仓库用 cfg.branch,工作区用 ws/<id> */
450
+ branchOfRepo(repo, cfg) {
451
+ const r = String(repo || 'main');
452
+ if (r.startsWith('ws:')) return this.workspaceBranch(cfg, r.slice(3));
453
+ return cfg.branch;
454
+ }
455
+
456
+ /** 本机的工作区路径覆盖(「换位置」是本机专属设置,不随仓库同步) */
457
+ workspacePathOverrides() {
458
+ const j = readJson(path.join(this.home, 'storages', 'workspace-local-paths.json'));
459
+ const o = j && typeof j.overrides === 'object' && j.overrides ? j.overrides : {};
460
+ return o;
461
+ }
462
+
463
+ setWorkspacePathOverride(id, p) {
464
+ const file = path.join(this.home, 'storages', 'workspace-local-paths.json');
465
+ const j = readJson(file) || {};
466
+ if (!j.overrides || typeof j.overrides !== 'object') j.overrides = {};
467
+ if (p) j.overrides[id] = String(p);
468
+ else delete j.overrides[id];
469
+ fs.mkdirSync(path.dirname(file), { recursive: true });
470
+ fs.writeFileSync(file, JSON.stringify(j, null, 2) + '\n', 'utf8');
471
+ return p || null;
472
+ }
473
+
474
+ /**
475
+ * 解析一个工作区在【本机】应该使用的真实目录。
476
+ * 顺序:本机覆盖 → workspaceBase 配置 → 记录路径(本机存在)→ 主目录前缀重映射。
477
+ * 换台电脑时用户名往往不同(/home/alice/x → /home/bob/x),不重映射就会往
478
+ * 一个本机并不存在的绝对路径里写东西。
479
+ */
480
+ resolveWorkspacePath(ws, cfg) {
481
+ const override = this.workspacePathOverrides()[ws.id];
482
+ if (typeof override === 'string' && override.trim()) return { path: override.trim(), source: 'override' };
483
+ const base = String(cfg.workspaceBase || '').trim();
484
+ if (base) return { path: path.join(base, path.basename(ws.path)), source: 'base' };
485
+ const recorded = String(ws.path || '');
486
+ if (!recorded) return { path: null, source: 'none' };
487
+ if (fs.existsSync(recorded)) return { path: recorded, source: 'recorded' };
488
+ const remapped = remapHomePrefix(recorded);
489
+ if (remapped && remapped !== recorded) return { path: remapped, source: 'remapped', recorded };
490
+ return { path: recorded, source: 'recorded' };
491
+ }
492
+
493
+ async ensureWorkspaceMirror(id, realPath, cfg) {
494
+ const gitdir = this.workspaceGitDir(id);
495
+ const branch = this.workspaceBranch(cfg, id);
496
+ if (!fs.existsSync(gitdir)) {
497
+ fs.mkdirSync(path.dirname(gitdir), { recursive: true });
498
+ const init = await this.git(['init', '--bare', gitdir]);
499
+ if (!this.ok(init)) return { ok: false, error: `工作区影子仓库初始化失败:${clip(init.err || init.code)}` };
500
+ await this.gitW(gitdir, realPath, ['symbolic-ref', 'HEAD', 'refs/heads/' + branch]);
501
+ await this.gitW(gitdir, realPath, ['config', 'commit.gpgsign', 'false']);
502
+ }
503
+ await this.gitW(gitdir, realPath, ['config', 'core.bare', 'false']);
504
+ await this.gitW(gitdir, realPath, ['config', 'core.worktree', realPath]);
505
+ await this.gitW(gitdir, realPath, ['config', 'core.autocrlf', 'false']);
506
+ await this.gitW(gitdir, realPath, ['config', 'core.safecrlf', 'false']);
507
+ await this.gitW(gitdir, realPath, ['config', 'user.name', cfg.gitUserName]);
508
+ await this.gitW(gitdir, realPath, ['config', 'user.email', cfg.gitUserEmail]);
509
+ if (cfg.remote) {
510
+ const cur = await this.gitW(gitdir, realPath, ['remote', 'get-url', 'origin']);
511
+ if (!this.ok(cur)) await this.gitW(gitdir, realPath, ['remote', 'add', 'origin', cfg.remote]);
512
+ else if (cur.out.trim().replace(/\/+$/, '') !== String(cfg.remote).replace(/\/+$/, '')) {
513
+ await this.gitW(gitdir, realPath, ['remote', 'set-url', 'origin', cfg.remote]);
514
+ }
515
+ await this.gitW(gitdir, realPath, ['config', 'remote.origin.fetch', `+refs/heads/${branch}:refs/remotes/origin/${branch}`]);
516
+ }
517
+ // 忽略规则写进影子仓库的 info/exclude —— 不往用户的真实项目文件夹里塞东西。
518
+ // 两类条目写法不同,绝不能一律加尾斜杠(`*.log/` 是无效规则):
519
+ // 目录名 → 带尾斜杠
520
+ // 用户的 gitignore 规则 → 原样透传(含 ! 例外)
521
+ try {
522
+ const exclPath = path.join(gitdir, 'info', 'exclude');
523
+ const dirs = new Set(['.git']);
524
+ for (const x of ['node_modules', '.pnpm-store', '.npm-cache', ...(cfg.workspaceExtraIgnore || [])]) {
525
+ const t = String(x || '').trim().replace(/^\/+|\/+$/g, '');
526
+ if (t) dirs.add(t);
527
+ }
528
+ // 嵌套 git 仓库:父仓库会把它们记成 gitlink,工作树一改动父仓库提交必失败
529
+ try {
530
+ for (const ent of fs.readdirSync(realPath, { withFileTypes: true })) {
531
+ if (ent.isDirectory() && fs.existsSync(path.join(realPath, ent.name, '.git'))) dirs.add(ent.name);
532
+ }
533
+ } catch { /* 目录读不到就算了 */ }
534
+ const userRules = this.workspaceIgnoreRules(id, realPath);
535
+ const lines = [
536
+ '# 由 dsh-chat-sync 生成 —— 手工改动会在下次同步时被覆盖',
537
+ `# 要加自己的规则,请编辑 ${this.workspaceIgnoreFilePath(realPath)}`,
538
+ '',
539
+ '# 依赖目录 / 嵌套 git 仓库(自动排除)',
540
+ ...[...dirs].map((d) => d + '/'),
541
+ '',
542
+ ];
543
+ if (userRules.length) lines.push('# 本机忽略规则(来自忽略文件与配置)', ...userRules, '');
544
+ lines.push('# 忽略规则文件本身不参与同步(它是本机专属设置)', ...WORKSPACE_IGNORE_NAMES, '');
545
+ fs.mkdirSync(path.dirname(exclPath), { recursive: true });
546
+ fs.writeFileSync(exclPath, lines.join('\n'), 'utf8');
547
+ } catch { /* ignore */ }
548
+ return { ok: true, gitdir, branch };
549
+ }
550
+
551
+ /* ================= 三路文本合并 ================= */
552
+
553
+ tmpDir() {
554
+ const d = path.join(this.home, '.chat-sync', 'tmp');
555
+ fs.mkdirSync(d, { recursive: true });
556
+ return d;
557
+ }
558
+
559
+ pendingRoot() { return path.join(this.home, '.chat-sync', 'pending'); }
560
+
561
+ /**
562
+ * 用 git 自己的三路合并处理普通文本文件:非重叠改动能自动合上。
563
+ * 这正是旧插件「非本质冲突也不合并」的反面。
564
+ * @returns {{ok:true,text:string}|{ok:false,reason:string,marked?:string}}
565
+ */
566
+ async threeWayMerge(relPath, baseText, oursText, theirsText) {
567
+ const dir = this.tmpDir();
568
+ const tag = randomUUID().slice(0, 8);
569
+ const fBase = path.join(dir, `${tag}.base`);
570
+ const fOurs = path.join(dir, `${tag}.ours`);
571
+ const fTheirs = path.join(dir, `${tag}.theirs`);
572
+ try {
573
+ fs.writeFileSync(fBase, baseText ?? '');
574
+ fs.writeFileSync(fOurs, oursText ?? '');
575
+ fs.writeFileSync(fTheirs, theirsText ?? '');
576
+ // --diff3 让冲突块同时给出三方内容;-p 输出到 stdout
577
+ const r = await this.git(['merge-file', '-p', '--diff3', fOurs, fBase, fTheirs], { timeoutMs: 60000 });
578
+ const text = r.out;
579
+ if (r.code === 0) {
580
+ if (/\.json$/i.test(relPath)) {
581
+ try { JSON.parse(text); } catch { return { ok: false, reason: 'json-invalid-after-merge' }; }
582
+ }
583
+ return { ok: true, text };
584
+ }
585
+ if (r.code > 0) return { ok: false, reason: 'text-overlap', marked: text };
586
+ return { ok: false, reason: 'merge-file-failed' };
587
+ } finally {
588
+ for (const f of [fBase, fOurs, fTheirs]) { try { fs.unlinkSync(f); } catch { /* ignore */ } }
589
+ }
590
+ }
591
+
592
+ /* ================= 冲突暂存 ================= */
593
+
594
+ newPendingStore(run) {
595
+ const id = `${ts()}-${randomUUID().slice(0, 8)}`;
596
+ const dir = path.join(this.pendingRoot(), id);
597
+ fs.mkdirSync(path.join(dir, 'files'), { recursive: true });
598
+ return { id, dir, conflicts: [], _n: 0 };
599
+ }
600
+
601
+ savePending(store) {
602
+ const manifest = { ...store, id: store.id, dir: undefined, createdAt: Date.now() };
603
+ delete manifest.dir;
604
+ fs.writeFileSync(path.join(store.dir, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8');
605
+ return manifest;
606
+ }
607
+
608
+ /** 记录一个冲突:远端版本单独落盘,本机文件一个字节都不动 */
609
+ async recordConflict(store, { repo, relPath, kind, reason, baseRef, theirsRef, localRef, side = 'pull', ...extra }) {
610
+ // 同一个路径在同一轮里可能被两条路径先后发现(会话预合并 + git 合并)。
611
+ // 它们描述的是同一件事,按 id 去重,避免界面上出现两条一模一样的冲突。
612
+ const id = `${repo}:${relPath}`;
613
+ const already = store.conflicts.find((x) => x.id === id);
614
+ if (already) return already;
615
+ const n = ++store._n;
616
+ const theirsFile = path.join(store.dir, 'files', `${n}-theirs`);
617
+ let theirsOk = false;
618
+ try {
619
+ if (theirsRef === null) {
620
+ // 远端删除了这个文件:没有内容可留档
621
+ } else if (Buffer.isBuffer(theirsRef) || typeof theirsRef === 'string') {
622
+ fs.writeFileSync(theirsFile, theirsRef);
623
+ theirsOk = true;
624
+ } else {
625
+ const r = await this.git(['show', theirsRef], { binary: true, timeoutMs: 120000 });
626
+ if (this.ok(r)) { fs.writeFileSync(theirsFile, r.out); theirsOk = true; }
627
+ }
628
+ } catch { /* 取不到就只记录路径,稍后预览会说明 */ }
629
+ const conflict = {
630
+ id,
631
+ repo,
632
+ side,
633
+ path: relPath,
634
+ kind,
635
+ reason,
636
+ baseRef: baseRef || null,
637
+ theirsRef: theirsRef === null ? null : (Buffer.isBuffer(theirsRef) || typeof theirsRef === 'string' ? '(inline)' : theirsRef),
638
+ localRef: localRef || null,
639
+ theirsCopy: theirsOk ? path.relative(store.dir, theirsFile).replace(/\\/g, '/') : null,
640
+ theirsDeleted: theirsRef === null,
641
+ ...extra,
642
+ };
643
+ store.conflicts.push(conflict);
644
+ return conflict;
645
+ }
646
+
647
+ /** 列出所有待裁决的冲突 */
648
+ listPending() {
649
+ const root = this.pendingRoot();
650
+ const out = [];
651
+ let ids = [];
652
+ try { ids = fs.readdirSync(root, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name); } catch { return out; }
653
+ for (const id of ids) {
654
+ const manifestPath = path.join(root, id, 'manifest.json');
655
+ const j = readJson(manifestPath);
656
+ if (!j) continue;
657
+ out.push({ ...j, id, dir: path.join(root, id) });
658
+ }
659
+ out.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
660
+ return out;
661
+ }
662
+
663
+ /** 把某个路径从待裁决记录里摘掉(用于「强制覆盖」这种显式决定);记录空了就整条删除 */
664
+ clearPendingForPath(repo, relPath) {
665
+ let changed = false;
666
+ for (const p of this.listPending()) {
667
+ const before = p.conflicts.length;
668
+ const rest = p.conflicts.filter((c) => !(c.repo === repo && c.path === relPath));
669
+ if (rest.length === before) continue;
670
+ changed = true;
671
+ if (!rest.length) { this.clearPending(p.id); continue; }
672
+ const manifest = { ...p, conflicts: rest, id: p.id };
673
+ delete manifest.dir;
674
+ fs.writeFileSync(path.join(p.dir, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8');
675
+ }
676
+ return changed;
677
+ }
678
+
679
+ clearPending(id) {
680
+ const dir = path.join(this.pendingRoot(), id);
681
+ if (!dir.startsWith(this.pendingRoot())) return;
682
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
683
+ }
684
+
685
+ /* ================= 归属 / 范围解析 ================= */
686
+
687
+ /**
688
+ * 把「范围」翻译成一串主仓库相对路径。
689
+ * all → 整个仓库;session → 选中会话的目录 + 投影缓存 + 工作区登记表。
690
+ */
691
+ resolveMainPaths(opts) {
692
+ if (opts.scope === 'all') return { mode: 'all', paths: [], sessionIds: [] };
693
+ const ids = Array.isArray(opts.sessionIds) ? opts.sessionIds.filter(Boolean) : [];
694
+ if (!ids.length) throw new Error('请选择至少一个会话,或改为「全部会话」。');
695
+ const sessions = this.localSessions().filter((s) => ids.includes(s.id));
696
+ const paths = new Set();
697
+ const found = new Set();
698
+ for (const s of sessions) {
699
+ found.add(s.id);
700
+ paths.add(s.relDir);
701
+ const pc = projCachePath(this.home, s.id);
702
+ if (fs.existsSync(pc)) paths.add(normRel(path.relative(this.home, pc)));
703
+ }
704
+ paths.add('storages/workspace.json');
705
+ const missing = ids.filter((id) => !found.has(id));
706
+ return { mode: 'session', paths: [...paths], sessionIds: [...found], missing };
707
+ }
708
+
709
+ /* ================= fetch ================= */
710
+
711
+ /**
712
+ * fetch 主仓库。
713
+ * 关键细节:远端还是空仓库时 `git fetch origin --prune` 会以
714
+ * 「无法找到远程引用 refs/heads/<branch>」失败 —— 那不是错误,
715
+ * 而是「第一次上传」的正常起点。这里用 ls-remote 区分这两者。
716
+ * @returns {{ok:true, remoteMissing?:boolean}|{ok:false, error:string}}
717
+ */
718
+ async fetchMain(run, cfg) {
719
+ const fetch = await this.git(['fetch', 'origin', '--prune', '--progress'], {
720
+ timeoutMs: 600000,
721
+ onStderr: (d) => logTransfer(run.progress, 'fetch', d),
722
+ });
723
+ if (this.ok(fetch)) return { ok: true };
724
+ const probe = await this.git(['ls-remote', '--heads', 'origin', `refs/heads/${cfg.branch}`], { timeoutMs: 120000 });
725
+ if (this.ok(probe) && !probe.out.trim()) {
726
+ run.progress.info('fetch', `远端尚无 ${cfg.branch} 分支,执行首次上传。`);
727
+ return { ok: true, remoteMissing: true };
728
+ }
729
+ return { ok: false, error: authOrFetchError(fetch) };
730
+ }
731
+
732
+ /* ================= 运行入口 ================= */
733
+
734
+ /** 当前是否已有一次操作在跑 */
735
+ get busy() { return Boolean(this.inFlight); }
736
+
737
+ /**
738
+ * 执行一次 push 或 pull。同一时刻只允许一个操作。
739
+ * @param {{direction:'push'|'pull', scope:'session'|'all', sessionIds?:string[],
740
+ * includeWorkspace?:boolean, workspaceIds?:string[], reason?:string}} opts
741
+ */
742
+ execute(opts) {
743
+ if (this.inFlight) {
744
+ return Promise.resolve({ ok: false, error: '已有同步任务进行中,请稍后重试。', runId: this.currentRun?.runId || null });
745
+ }
746
+ const run = new RunState({
747
+ runId: randomUUID(),
748
+ direction: opts.direction,
749
+ scope: opts.scope,
750
+ reason: opts.reason || '',
751
+ });
752
+ this.currentRun = run;
753
+ const p = (async () => {
754
+ try {
755
+ this._cfg = this.loadConfig();
756
+ if (this.gitMissing) throw new Error('本机没有找到 git,请先安装 Git 后重试。');
757
+ const probe = await this.git(['--version'], { timeoutMs: 20000 });
758
+ if (this.gitMissing || !this.ok(probe)) throw new Error('本机没有找到 git,请先安装 Git 后重试。');
759
+ run.progress.step('start', `${opts.direction === 'push' ? '推送(本机 → 远端)' : '取回(远端 → 本机)'}开始:范围 ${opts.scope === 'all' ? '全部会话' : '选中的会话'}`);
760
+ const result = opts.direction === 'push'
761
+ ? await this.push(run, opts)
762
+ : await this.pull(run, opts);
763
+ run.result = result;
764
+ run.progress.done('end', summarize(result));
765
+ return { ok: !result.error, runId: run.runId, result };
766
+ } catch (e) {
767
+ const msg = String(e?.message || e);
768
+ run.error = msg;
769
+ run.progress.error('end', msg);
770
+ return { ok: false, runId: run.runId, error: msg };
771
+ } finally {
772
+ run.running = false;
773
+ run.finishedAt = Date.now();
774
+ this.lastRun = run;
775
+ this.inFlight = null;
776
+ }
777
+ })();
778
+ this.inFlight = p;
779
+ return p;
780
+ }
781
+
782
+ /** 供 /api/run 用:立即返回 runId,操作在后台继续 */
783
+ start(opts) {
784
+ if (this.inFlight) return { ok: false, error: '已有同步任务进行中,请稍后重试。', runId: this.currentRun?.runId || null };
785
+ const started = this.execute(opts); // 故意不 await
786
+ // execute 是同步进入 async 主体的,这里马上能拿到 runId
787
+ const runId = this.currentRun?.runId || null;
788
+ started.catch(() => { /* 错误已经写进 run */ });
789
+ return { ok: true, runId };
790
+ }
791
+
792
+ /* ================= 本机同步偏好(忽略清单 / 已记住的裁决) ================= */
793
+
794
+ /**
795
+ * 本机专属的同步偏好。**故意放在 .chat-sync/ 下**(已被 gitignore)而不是
796
+ * chat-sync.json —— 后者会随仓库同步,那样 A 机器的忽略会静默改变 B 机器的行为。
797
+ */
798
+ ignoreConfigPath() { return path.join(this.home, '.chat-sync', 'ignore.json'); }
799
+
800
+ ignoreConfig() {
801
+ const j = readJson(this.ignoreConfigPath());
802
+ return {
803
+ sessions: Array.isArray(j?.sessions) ? j.sessions.slice() : [],
804
+ workspaces: Array.isArray(j?.workspaces) ? j.workspaces.slice() : [],
805
+ workspaceFiles: (j && typeof j.workspaceFiles === 'object' && j.workspaceFiles) ? j.workspaceFiles : {},
806
+ decisions: (j && typeof j.decisions === 'object' && j.decisions) ? j.decisions : {},
807
+ };
808
+ }
809
+
810
+ writeIgnoreConfig(patch) {
811
+ const next = { ...this.ignoreConfig(), ...patch };
812
+ fs.mkdirSync(path.dirname(this.ignoreConfigPath()), { recursive: true });
813
+ fs.writeFileSync(this.ignoreConfigPath(), JSON.stringify(next, null, 2) + '\n', 'utf8');
814
+ return next;
815
+ }
816
+
817
+ isSessionIgnored(id) { return this.ignoreConfig().sessions.includes(String(id)); }
818
+ isWorkspaceIgnored(id) { return this.ignoreConfig().workspaces.includes(String(id)); }
819
+
820
+ /** 工作区忽略规则文件的绝对路径(用户在项目根目录下自己编辑的那个文件) */
821
+ workspaceIgnoreFilePath(realPath) {
822
+ return path.join(realPath, WORKSPACE_IGNORE_NAMES[0]);
823
+ }
824
+
825
+ /** 读工作区忽略规则文件的内容(不存在返回 '') */
826
+ readWorkspaceIgnoreFile(realPath) {
827
+ try {
828
+ const p = this.workspaceIgnoreFilePath(realPath);
829
+ return fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : '';
830
+ } catch { return ''; }
831
+ }
832
+
833
+ /** 写工作区忽略规则文件 */
834
+ writeWorkspaceIgnoreFile(realPath, content) {
835
+ const p = this.workspaceIgnoreFilePath(realPath);
836
+ fs.mkdirSync(path.dirname(p), { recursive: true });
837
+ fs.writeFileSync(p, String(content ?? ''), 'utf8');
838
+ return p;
839
+ }
840
+
841
+ /** 没有就创建一个带说明的忽略文件(用户可以随时自己编辑;只读他自己的文件) */
842
+ ensureWorkspaceIgnoreFile(realPath, cfg) {
843
+ if (cfg.createWorkspaceIgnoreFile === false) return null;
844
+ const p = this.workspaceIgnoreFilePath(realPath);
845
+ try {
846
+ if (fs.existsSync(p)) return p;
847
+ fs.mkdirSync(path.dirname(p), { recursive: true });
848
+ fs.writeFileSync(p, WORKSPACE_IGNORE_TEMPLATE, 'utf8');
849
+ this.log('info', `已创建 ${p}(写入不想同步的文件,gitignore 语法;该文件不参与同步)`);
850
+ return p;
851
+ } catch { return null; }
852
+ }
853
+
854
+ /**
855
+ * 工作区里被忽略的规则(gitignore 语法)。
856
+ * 两个来源:配置文件里的 workspaceFiles,以及工作目录下用户自己编辑的忽略文件。
857
+ * 用户的注释行保留 —— git 的 exclude 本身就把 `#` 当注释。
858
+ */
859
+ workspaceIgnoreRules(wsId, realPath) {
860
+ const cfg = this.ignoreConfig();
861
+ const rules = [];
862
+ for (const r of (Array.isArray(cfg.workspaceFiles?.[wsId]) ? cfg.workspaceFiles[wsId] : [])) {
863
+ const t = String(r ?? '').trim();
864
+ if (t) rules.push(t);
865
+ }
866
+ for (const name of WORKSPACE_IGNORE_NAMES) {
867
+ try {
868
+ const p = path.join(realPath, name);
869
+ if (!fs.existsSync(p)) continue;
870
+ for (const line of fs.readFileSync(p, 'utf8').split('\n')) {
871
+ const t = line.replace(/\s+$/, '');
872
+ if (t.trim()) rules.push(t);
873
+ }
874
+ } catch { /* 读不到就算了 */ }
875
+ }
876
+ return rules;
877
+ }
878
+
879
+ /** 本仓库里所有「还没裁决」的路径(未决的冲突必须先保护住,不能被自动覆盖) */
880
+ pendingPathSet(repo) {
881
+ const set = new Set();
882
+ for (const p of this.listPending()) {
883
+ for (const c of p.conflicts || []) if (c.repo === repo) set.add(c.path);
884
+ }
885
+ return set;
886
+ }
887
+
888
+ /**
889
+ * 本机决定「这个路径不参与本次操作」的统一判定:
890
+ * - 会话在忽略清单里(含它的投影缓存)
891
+ * - 该路径上有尚未裁决的冲突(两边都改过的,不能自动选边)
892
+ */
893
+ pathIsMuted(rel, repo, pendingSet = null) {
894
+ const p = normRel(rel);
895
+ const pend = pendingSet || this.pendingPathSet(repo);
896
+ if (pend.has(p)) return true;
897
+ const id = isSessionLogPath(p) ? sessionIdOfPath(p) : sessionCacheIdOf(p);
898
+ if (id && this.isSessionIgnored(id)) return true;
899
+ return false;
900
+ }
901
+
902
+ /** 记住一个单元的裁决(例如"这个删除要/不要同步"),避免每次推送反复问同一件事 */
903
+ unitDecision(key) {
904
+ const d = this.ignoreConfig().decisions[key];
905
+ return d && typeof d.choice === 'string' ? d : null;
906
+ }
907
+
908
+ setUnitDecision(key, choice) {
909
+ const cfg = this.ignoreConfig();
910
+ const decisions = { ...cfg.decisions };
911
+ if (choice) decisions[key] = { choice, at: Date.now() };
912
+ else delete decisions[key];
913
+ this.writeIgnoreConfig({ decisions });
914
+ return choice || null;
915
+ }
916
+
917
+ /* ================= 单元分类(规格表 A) ================= */
918
+
919
+ /** 一次 ls-tree 拿到整个 ref 的 path → {sha, mode},避免逐文件起 git 进程 */
920
+ async treeIndex(g, ref) {
921
+ const map = new Map();
922
+ const r = await g(['ls-tree', '-r', '-z', ref], { timeoutMs: 300000 });
923
+ if (!this.ok(r)) return map;
924
+ for (const rec of String(r.out).split('\0')) {
925
+ if (!rec) continue;
926
+ const tab = rec.indexOf('\t');
927
+ if (tab < 0) continue;
928
+ const meta = rec.slice(0, tab).split(' ');
929
+ map.set(normRel(rec.slice(tab + 1)), { sha: meta[2], mode: meta[0] });
930
+ }
931
+ return map;
932
+ }
933
+
934
+ /** from 与 to 之间改动的路径 → 状态字母(A/M/D/T) */
935
+ async changedPaths(g, from, to) {
936
+ const out = new Map();
937
+ const r = await g(['diff', '--name-status', '--no-renames', '-z', from, to], { timeoutMs: 300000 });
938
+ if (!this.ok(r)) return out;
939
+ const parts = String(r.out).split('\0');
940
+ for (let i = 0; i + 1 < parts.length; i += 2) {
941
+ const st = parts[i];
942
+ const p = parts[i + 1];
943
+ if (!st || !p) continue;
944
+ out.set(normRel(p), st[0]);
945
+ }
946
+ return out;
947
+ }
948
+
949
+ /**
950
+ * 把"本机相对 main 的全部差异"逐个单元分类。
951
+ * @param ctx {{ repo, g, ref, kindLabel, ignore?: (path)=>boolean }}
952
+ */
953
+ async classifyUnits(run, cfg, ctx) {
954
+ const { g, ref } = ctx;
955
+ const remoteHead = await g(['rev-parse', '--verify', ref]);
956
+ if (!this.ok(remoteHead)) return { ok: true, noRemote: true, units: [], remoteHead: null, localHead: null, base: null };
957
+ const remote = remoteHead.out.trim();
958
+ const localHead = await g(['rev-parse', '--verify', 'HEAD']);
959
+ if (!this.ok(localHead)) return { ok: true, noLocal: true, units: [], remoteHead: remote, localHead: null, base: null };
960
+ const local = localHead.out.trim();
961
+
962
+ // 没有共同祖先时**不能假装知道谁改了什么**:三方比较不成立。
963
+ // 这里把 base 置空,于是"两边都有且内容不同"会落到 A8(双方独立新增),
964
+ // 由内容层尝试合并,合不了就交给用户裁决 —— 而不是武断地选一边。
965
+ const mb = await g(['merge-base', local, remote]);
966
+ const unrelated = !this.ok(mb);
967
+ if (unrelated) run.progress.info('unit', '本机与远端无共同历史,按「双方各自新增」逐条处理。');
968
+ const base = unrelated ? null : mb.out.trim();
969
+
970
+ const [tB, tL, tR] = await Promise.all([
971
+ unrelated ? Promise.resolve(new Map()) : this.treeIndex(g, base),
972
+ this.treeIndex(g, local),
973
+ this.treeIndex(g, remote),
974
+ ]);
975
+ const mine = await this.changedPaths(g, base, local);
976
+ const theirs = await this.changedPaths(g, base, remote);
977
+
978
+ const paths = [...new Set([...mine.keys(), ...theirs.keys()])].sort();
979
+ const units = [];
980
+ for (const p of paths) {
981
+ if (ctx.ignore && ctx.ignore(p)) { units.push({ path: p, state: 'IGNORED' }); continue; }
982
+ const b = tB.get(p) || null;
983
+ const l = tL.get(p) || null;
984
+ const r = tR.get(p) || null;
985
+ units.push({ path: p, state: classifyState(b, l, r), base: b, ours: l, theirs: r, kind: unitKindOf(p) });
986
+ }
987
+ return { ok: true, base, unrelated, localHead: local, remoteHead: remote, units };
988
+ }
989
+
990
+ /** 取一个 blob 的内容(二进制安全) */
991
+ async readBlob(g, sha) {
992
+ const r = await g(['cat-file', 'blob', sha], { binary: true, timeoutMs: 120000 });
993
+ return this.ok(r) ? r.out : null;
994
+ }
995
+
996
+ /**
997
+ * 单个单元在「推送」方向怎么处理。
998
+ * @returns {{overlay?:{content:Buffer|null,mode:string}, hold?:{reason:string}}}
999
+ * overlay = 要叠加进交付树的内容(content=null 表示删除)
1000
+ * hold = 需要人裁决,交付树保留 main 的版本
1001
+ */
1002
+ async resolveUnitForDelivery(run, cfg, ctx, unit, g) {
1003
+ const key = `${ctx.repo}:${unit.path}`;
1004
+ const decision = this.unitDecision(key);
1005
+
1006
+ // 用户在待裁决里显式选了「保留本机 / 同步删除」→ 无条件采用本端,跳过一切合并判断
1007
+ if (ctx.forceOurs && ctx.forceOurs.has(unit.path)) {
1008
+ const forced = ctx.forceOurs.get(unit.path);
1009
+ if (forced === 'delete') return { overlay: { content: null, mode: '100644' } };
1010
+ return { overlay: { content: await this.readBlob(g, unit.ours.sha), mode: unit.ours.mode } };
1011
+ }
1012
+ const theirsContent = () => (unit.theirs ? this.readBlob(g, unit.theirs.sha) : null);
1013
+
1014
+ switch (unit.state) {
1015
+ case 'A2': // 仅本端改
1016
+ case 'A6': // 本端新增
1017
+ return { overlay: { content: await this.readBlob(g, unit.ours.sha), mode: unit.ours.mode } };
1018
+
1019
+ case 'A4': // 两边都改、纯追加
1020
+ case 'A8': { // 双方独立新增同一路径(含无共同历史的情况)
1021
+ if (unit.kind === 'cache') return { overlay: { content: await this.readBlob(g, unit.ours.sha), mode: unit.ours.mode } };
1022
+ const merged = await this.mergeUnitContent(g, unit, unit.kind);
1023
+ if (merged.ok) return { overlay: { content: merged.content, mode: unit.ours.mode } };
1024
+ return { hold: { reason: merged.reason || 'no-common-base' } };
1025
+ }
1026
+
1027
+ case 'A5': { // 两边都改
1028
+ if (unit.kind === 'cache') {
1029
+ // 唯一自动裁决的例外:派生数据,两个方向都会重建(见规格 §7.1)
1030
+ run.progress.info('unit', `${unit.path}:会话投影缓存为派生数据,采用本机版本(DSH 会重建)。`);
1031
+ return { overlay: { content: await this.readBlob(g, unit.ours.sha), mode: unit.ours.mode } };
1032
+ }
1033
+ const merged = await this.mergeUnitContent(g, unit, unit.kind);
1034
+ if (merged.ok) return { overlay: { content: merged.content, mode: unit.ours.mode } };
1035
+ return { hold: { reason: merged.reason } };
1036
+ }
1037
+
1038
+ case 'A9': { // 本端删除、远端未改 —— 删除绝不自动传播
1039
+ if (decision?.choice === 'delete') return { overlay: { content: null, mode: '100644' } };
1040
+ if (decision?.choice === 'keep') return {}; // 已经决定「只在本机删除」→ main 保持原样,不再打扰
1041
+ return { hold: { reason: 'delete-local' } };
1042
+ }
1043
+
1044
+ case 'A12': { // 本端删除 + 远端修改
1045
+ if (decision?.choice === 'delete') return { overlay: { content: null, mode: '100644' } };
1046
+ if (decision?.choice === 'keep') return {}; // 已经决定改用 main 的版本(本机那份也已恢复)→ 无改动
1047
+ return { hold: { reason: 'delete-modify' } };
1048
+ }
1049
+
1050
+ case 'A13': { // 远端删除 + 本端修改
1051
+ if (decision?.choice === 'keep') return { overlay: { content: await this.readBlob(g, unit.ours.sha), mode: unit.ours.mode } };
1052
+ if (decision?.choice === 'delete') return {}; // 本机也删了、main 也没有 → 无操作
1053
+ return { hold: { reason: 'modify-delete' } };
1054
+ }
1055
+
1056
+ // A1 已同步 / A3 仅远端改 / A7 远端新增 / A10 双方删除 / A11 远端删除 → 交付树天然保持 main 的样子
1057
+ default:
1058
+ return {};
1059
+ }
1060
+ }
1061
+
1062
+ /** 按单元类型做内容层面的合并(会话日志走纯追加规则,文本走三路合并) */
1063
+ async mergeUnitContent(g, unit, kind) {
1064
+ const oursBuf = await this.readBlob(g, unit.ours.sha);
1065
+ const theirsBuf = this.theirs ? null : null; // 占位,下面统一取
1066
+ const theirs = unit.theirs ? await this.readBlob(g, unit.theirs.sha) : null;
1067
+ if (!oursBuf || !theirs) return { ok: false, reason: 'unreadable' };
1068
+ void theirsBuf;
1069
+
1070
+ if (kind === 'session-log') {
1071
+ const m = mergeSessionLog(oursBuf, theirs);
1072
+ if (m.ok) return { ok: true, content: m.buf };
1073
+ return { ok: false, reason: m.reason === 'divergent' ? 'divergent-session-history' : m.reason };
1074
+ }
1075
+ if (kind === 'text' && !looksBinary(oursBuf) && !looksBinary(theirs)) {
1076
+ const baseText = unit.base ? (await this.readBlob(g, unit.base.sha))?.toString('utf8') ?? '' : '';
1077
+ const m = await this.threeWayMerge(unit.path, baseText, oursBuf.toString('utf8'), theirs.toString('utf8'));
1078
+ if (m.ok) return { ok: true, content: Buffer.from(m.text, 'utf8') };
1079
+ return { ok: false, reason: m.reason };
1080
+ }
1081
+ return { ok: false, reason: 'binary-both-changed' };
1082
+ }
1083
+
1084
+ /** 把交付过程中"留下来的"单元登记成待裁决项(含远端版本留档,供界面展示两侧内容) */
1085
+ async recordHeldUnits(run, store, build, ctx) {
1086
+ if (!build.held?.length) return [];
1087
+ const out = [];
1088
+ for (const h of build.held) {
1089
+ const meta = this.describeConflictPath(h.path);
1090
+ const theirs = h.theirs ? await this.readBlob(ctx.g, h.theirs.sha) : null;
1091
+ out.push(await this.recordConflict(store, {
1092
+ repo: ctx.repo,
1093
+ side: 'delivery',
1094
+ relPath: h.path,
1095
+ kind: h.kind === 'session-log' ? 'session-log' : 'file',
1096
+ reason: h.reason,
1097
+ theirsRef: theirs,
1098
+ sessionId: meta.sessionId,
1099
+ title: meta.title,
1100
+ state: h.state,
1101
+ workspaceLabel: ctx.label || null,
1102
+ }));
1103
+ }
1104
+ return out;
1105
+ }
1106
+
1107
+ /**
1108
+ * 构造「交付提交」:起点是 main 的树,叠加本端能交付的单元,
1109
+ * 父提交是 (main, 本机 HEAD),然后原样推给 main。
1110
+ *
1111
+ * 全程不碰本机工作树、不碰本机 HEAD、不碰真实 index。
1112
+ */
1113
+ async buildDeliveryCommit(run, cfg, store, ctx) {
1114
+ const log = run.progress;
1115
+ const { g, ref, kindLabel = '主数据' } = ctx;
1116
+ const cls = await this.classifyUnits(run, cfg, ctx);
1117
+ if (!cls.ok || cls.noRemote) return { ok: false, reason: 'no-remote' };
1118
+ if (cls.noLocal) return { ok: true, commit: null, delivered: [], held: [], reason: 'no-local-commit' };
1119
+
1120
+ const held = [];
1121
+ const overlays = [];
1122
+ for (const unit of cls.units) {
1123
+ const res = await this.resolveUnitForDelivery(run, cfg, ctx, unit, g);
1124
+ if (res.hold) {
1125
+ held.push({ ...unit, reason: res.hold.reason });
1126
+ log.warn('unit', `${kindLabel} ${unit.path}:${holdReasonText(res.hold.reason)};交付树保留远端版本。`);
1127
+ continue;
1128
+ }
1129
+ if (res.overlay) overlays.push({ path: unit.path, ...res.overlay, unit });
1130
+ }
1131
+ if (!overlays.length) return { ok: true, commit: null, delivered: [], held, base: cls.base, nothingNew: true };
1132
+
1133
+ const idx = path.join(this.tmpDir(), 'deliver-index-' + randomUUID().slice(0, 8));
1134
+ const gi = (args, opts = {}) => g(args, { ...opts, env: { GIT_INDEX_FILE: idx } });
1135
+ try {
1136
+ const rt = await gi(['read-tree', cls.remoteHead]);
1137
+ if (!this.ok(rt)) return { ok: false, reason: 'error', error: `read-tree 失败:${clip(rt.err || rt.code)}` };
1138
+ for (const ov of overlays) {
1139
+ if (ov.content === null) {
1140
+ await gi(['update-index', '--force-remove', '--', ov.path]);
1141
+ continue;
1142
+ }
1143
+ const h = await g(['hash-object', '-w', '--stdin'], { stdin: ov.content, timeoutMs: 120000 });
1144
+ if (!this.ok(h)) return { ok: false, reason: 'error', error: `hash-object 失败:${clip(h.err || h.code)}` };
1145
+ const up = await gi(['update-index', '--add', '--cacheinfo', `${ov.mode || '100644'},${h.out.trim()},${ov.path}`]);
1146
+ if (!this.ok(up)) return { ok: false, reason: 'error', error: `update-index 失败:${clip(up.err || up.code)}` };
1147
+ }
1148
+ const wt = await gi(['write-tree']);
1149
+ if (!this.ok(wt)) return { ok: false, reason: 'error', error: `write-tree 失败:${clip(wt.err || wt.code)}` };
1150
+ const tree = wt.out.trim();
1151
+ const remoteTree = await g(['rev-parse', `${cls.remoteHead}^{tree}`]);
1152
+ if (this.ok(remoteTree) && remoteTree.out.trim() === tree) {
1153
+ return { ok: true, commit: null, delivered: [], held, base: cls.base, nothingNew: true };
1154
+ }
1155
+ const msg = `${cfg.commitMessage}: 交付 ${overlays.length} 项改动到 ${kindLabel} (${nowStamp()})`;
1156
+ const ct = await g(['commit-tree', tree, '-p', cls.remoteHead, '-p', cls.localHead, '-m', msg], { timeoutMs: 120000 });
1157
+ if (!this.ok(ct)) return { ok: false, reason: 'error', error: `commit-tree 失败:${clip(ct.err || ct.code)}` };
1158
+ return {
1159
+ ok: true,
1160
+ commit: ct.out.trim(),
1161
+ tree,
1162
+ message: msg,
1163
+ base: cls.base,
1164
+ remoteHead: cls.remoteHead,
1165
+ localHead: cls.localHead,
1166
+ delivered: overlays.map((o) => o.path),
1167
+ held,
1168
+ };
1169
+ } finally {
1170
+ try { fs.unlinkSync(idx); } catch { /* ignore */ }
1171
+ }
1172
+ }
1173
+
1174
+ /** 把交付提交推给远端分支;被拒(远端又动了)时重建一次 */
1175
+ async pushDelivery(run, cfg, store, ctx, attempt = 2) {
1176
+ const log = run.progress;
1177
+ for (let i = 0; i < attempt; i += 1) {
1178
+ const build = await this.buildDeliveryCommit(run, cfg, store, ctx);
1179
+ if (!build.ok) return build;
1180
+ if (!build.commit) return build;
1181
+ log.info('deliver', `已构造交付提交 ${build.commit.slice(0, 7)}(${build.delivered.length} 项改动)。`);
1182
+ // 必须用 ctx.g:工作区的交付提交只存在于那个影子仓库里,用主仓库的 git 会推错地方。
1183
+ const p = await ctx.g(['push', 'origin', `${build.commit}:refs/heads/${ctx.branch}`, '--progress'], {
1184
+ timeoutMs: 1800000,
1185
+ onStderr: (d) => logTransfer(log, 'push', d),
1186
+ });
1187
+ if (this.ok(p)) return build;
1188
+ const reject = /non-fast-forward|fetch first|\[rejected\]|stale info|cannot lock ref/i.test(p.err || '');
1189
+ if (!reject) return { ok: false, reason: 'error', error: `push 失败:${clip(p.err || p.code)}` };
1190
+ log.warn('deliver', `推送被拒(远端已更新),重新读取并重建交付提交(第 ${i + 1} 次)`);
1191
+ const refetch = await this.fetchMain(run, cfg);
1192
+ if (!refetch.ok) return { ok: false, reason: 'error', error: refetch.error };
1193
+ }
1194
+ return { ok: false, reason: 'error', error: '推送持续被拒,请稍后重试。' };
1195
+ }
1196
+
1197
+ /* ================= push ================= */
1198
+
1199
+ async push(run, opts) {
1200
+ const log = run.progress;
1201
+ const cfg = this._cfg;
1202
+ const result = { direction: 'push', scope: opts.scope, commits: [], pushed: [], conflicts: [], workspaces: [], pendingId: null };
1203
+ const store = this.newPendingStore(run);
1204
+
1205
+ log.step('repo', '检查本地仓库与远端配置');
1206
+ const repo = await this.ensureRepo(cfg);
1207
+ if (!repo.ok) { result.error = repo.error; return result; }
1208
+
1209
+ let remote = cfg.remote;
1210
+ if (!remote) {
1211
+ log.step('repo', '未配置远端,尝试通过 gh 创建或复用私有仓库');
1212
+ const boot = await this.bootstrapRemote(cfg);
1213
+ if (!boot.remote) { result.error = boot.error; return result; }
1214
+ remote = boot.remote;
1215
+ log.info('repo', `远端就绪:${remote}${boot.created ? '(已新建私有仓库)' : '(已复用现有私有仓库)'}`);
1216
+ cfg.remote = remote;
1217
+ } else {
1218
+ log.info('repo', `远端:${remote}(分支 ${cfg.branch})`);
1219
+ }
1220
+ if (!(await this.guardPrivate(run, remote))) { result.error = '远端未确认为私有仓库,已取消上传。'; return result; }
1221
+
1222
+ const scope = this.resolveMainPaths(opts);
1223
+ if (scope.missing?.length) log.warn('scope', `${scope.missing.length} 个会话在本机不存在,已跳过:${scope.missing.join(', ')}`);
1224
+ if (scope.mode === 'all') log.info('scope', '范围:全部会话与主数据(整个 DSH 数据目录)');
1225
+ else log.info('scope', `范围:${scope.sessionIds.length} 个会话及工作区登记表`, { sessions: scope.sessionIds });
1226
+
1227
+ // —— 1) 只暂存范围内的路径 ——
1228
+ log.step('stage', '暂存范围内的本地改动');
1229
+ let stagedPaths = [];
1230
+ if (scope.mode === 'all') {
1231
+ await this.git(['add', '-A'], { timeoutMs: 300000 });
1232
+ } else {
1233
+ stagedPaths = this.existingPaths(scope.paths);
1234
+ if (stagedPaths.length) {
1235
+ const r = await this.git(['add', '-A', '--', ...stagedPaths], { timeoutMs: 300000 });
1236
+ if (!this.ok(r)) log.warn('stage', `暂存部分路径失败:${clip(r.err || r.code)}`);
1237
+ }
1238
+ }
1239
+ // 只看(也只提交)本次范围内的路径:否则日志会把你没选的会话也列出来,看着像要一起上传。
1240
+ const st = await this.git(scope.mode === 'all' ? ['status', '--porcelain'] : ['status', '--porcelain', '--', ...stagedPaths]);
1241
+ const changed = this.ok(st) ? st.out.split('\n').map((l) => l.trim()).filter(Boolean) : [];
1242
+ if (!changed.length) {
1243
+ log.info('stage', '范围内无需提交的改动。');
1244
+ } else {
1245
+ log.info('stage', `将提交 ${changed.length} 项改动:`, { files: changed.slice(0, 40) });
1246
+ for (const line of changed.slice(0, 40)) log.info('stage', ' ' + line);
1247
+ if (changed.length > 40) log.info('stage', ` …另有 ${changed.length - 40} 项`);
1248
+ const msg = `${cfg.commitMessage}: ${describeScope(opts, scope)} (${nowStamp()})`;
1249
+ // 带 pathspec 提交:即使索引里还留着别处暂存的内容,也不会被这个提交带走。
1250
+ const cm = scope.mode === 'all'
1251
+ ? await this.git(['commit', '-m', msg])
1252
+ : await this.git(['commit', '-m', msg, '--', ...stagedPaths]);
1253
+ if (!this.ok(cm)) {
1254
+ result.error = `git commit 失败:${clip(cm.err || cm.out)}`;
1255
+ log.error('stage', result.error);
1256
+ return result;
1257
+ }
1258
+ const head = await this.git(['rev-parse', '--short', 'HEAD']);
1259
+ result.commits.push({ repo: 'main', sha: this.ok(head) ? head.out.trim() : null, message: msg });
1260
+ log.done('stage', `已提交本地快照 ${this.ok(head) ? head.out.trim() : ''}`);
1261
+ }
1262
+
1263
+ // —— 2) 读取远端,构造「交付提交」并推送 ——
1264
+ // 交付提交 = main 的树 + 本端能交付的单元,父提交是 (main, 本机 HEAD)。
1265
+ // 本机 HEAD / 工作树 / index 全程不动,所以本端永远不会被强迫拉取,也永远不会分叉。
1266
+ log.step('fetch', `读取远端 ${cfg.branch} 分支状态`);
1267
+ const fetched = await this.fetchMain(run, cfg);
1268
+ if (!fetched.ok) {
1269
+ result.error = fetched.error;
1270
+ log.error('fetch', result.error);
1271
+ return result;
1272
+ }
1273
+
1274
+ // 远端还没有这个分支 → 首次上传:没有远端树可作起点,直接推本机历史
1275
+ const remoteRef = await this.git(['rev-parse', '--verify', `refs/remotes/origin/${cfg.branch}`]);
1276
+ const localRef = await this.git(['rev-parse', '--verify', 'HEAD']);
1277
+ if (!this.ok(remoteRef)) {
1278
+ if (!this.ok(localRef)) {
1279
+ log.info('deliver', '本机无提交,远端亦无历史,无需推送。');
1280
+ return result;
1281
+ }
1282
+ log.step('deliver', `远端尚无 ${cfg.branch} 分支,执行首次上传`);
1283
+ const first = await this.git(['push', '-u', 'origin', `HEAD:refs/heads/${cfg.branch}`, '--progress'], {
1284
+ timeoutMs: 1800000,
1285
+ onStderr: (d) => logTransfer(log, 'push', d),
1286
+ });
1287
+ if (!this.ok(first)) {
1288
+ result.error = `首次上传失败:${clip(first.err || first.code)}`;
1289
+ result.hardError = true;
1290
+ log.error('deliver', result.error);
1291
+ return result;
1292
+ }
1293
+ result.pushed.push({ repo: 'main', sha: localRef.out.trim().slice(0, 7) });
1294
+ log.done('deliver', `主数据已首次上传至 ${remote}(${cfg.branch})。`);
1295
+ } else {
1296
+ log.step('deliver', '逐条判定,构造交付提交');
1297
+ const ctxMain = {
1298
+ repo: 'main',
1299
+ g: (args, opts) => this.git(args, opts),
1300
+ ref: `refs/remotes/origin/${cfg.branch}`,
1301
+ branch: cfg.branch,
1302
+ kindLabel: '主数据',
1303
+ label: '主数据',
1304
+ ignore: (p) => this.pathIsMuted(p, 'main'),
1305
+ };
1306
+ const build = await this.pushDelivery(run, cfg, store, ctxMain);
1307
+ if (!build.ok) {
1308
+ result.error = build.error || '构造交付提交失败。';
1309
+ result.hardError = true;
1310
+ log.error('deliver', result.error);
1311
+ return result;
1312
+ }
1313
+ if (build.delivered?.length) {
1314
+ result.delivered = build.delivered;
1315
+ result.pushed.push({ repo: 'main', sha: build.commit ? build.commit.slice(0, 7) : null });
1316
+ log.done('deliver', `远端已接受 ${build.delivered.length} 项本机改动;本机文件未改动。`);
1317
+ } else {
1318
+ log.info('deliver', '无可交付给远端的改动。');
1319
+ }
1320
+ await this.recordHeldUnits(run, store, build, ctxMain);
1321
+ }
1322
+
1323
+ // —— 3) 工作区文件 ——
1324
+ if (opts.includeWorkspace && !result.hardError) {
1325
+ const targets = this.workspaceTargets(opts);
1326
+ if (!targets.length) log.info('ws', '无匹配工作区。');
1327
+ else {
1328
+ log.step('ws', `开始同步 ${targets.length} 个工作区`);
1329
+ for (const ws of targets) {
1330
+ const r = await this.pushWorkspace(run, cfg, ws, result, store);
1331
+ result.workspaces.push(r);
1332
+ }
1333
+ }
1334
+ } else {
1335
+ log.info('ws', '本次不含工作区文件。');
1336
+ }
1337
+ if (store.conflicts.length) {
1338
+ result.pendingId = this.savePending(store).id;
1339
+ result.conflicts = store.conflicts;
1340
+ if (!result.error) result.error = `${store.conflicts.length} 项无法自动合并,已暂停并等待裁决(相关内容未上传)。`;
1341
+ }
1342
+ return result;
1343
+ }
1344
+
1345
+ existingPaths(paths) {
1346
+ return (paths || []).filter((p) => {
1347
+ try { return fs.existsSync(path.join(this.home, p)); } catch { return false; }
1348
+ });
1349
+ }
1350
+
1351
+ /** 选中会话 → 需要同步的工作区;未选会话则全部工作区 */
1352
+ workspaceTargets(opts) {
1353
+ const all = this.workspaceRecords();
1354
+ if (opts.includeWorkspaceAll || opts.scope === 'all') return all;
1355
+ if (Array.isArray(opts.workspaceIds) && opts.workspaceIds.length) {
1356
+ return all.filter((w) => opts.workspaceIds.includes(w.id));
1357
+ }
1358
+ const ids = Array.isArray(opts.sessionIds) ? opts.sessionIds : [];
1359
+ const owner = this.sessionToWorkspace();
1360
+ const wanted = new Set(ids.map((s) => owner.get(s)).filter(Boolean));
1361
+ if (!wanted.size) return [];
1362
+ return all.filter((w) => wanted.has(w.id));
1363
+ }
1364
+
1365
+ async pushWorkspace(run, cfg, ws, result, store) {
1366
+ const log = run.progress;
1367
+ const label = ws.title || path.basename(ws.path) || ws.id;
1368
+ if (this.isWorkspaceIgnored(ws.id)) {
1369
+ log.info('ws', `工作区「${label}」在本机被忽略,跳过。`);
1370
+ return { id: ws.id, title: label, path: ws.path, ignored: true };
1371
+ }
1372
+ const resolved = this.resolveWorkspacePath(ws, cfg);
1373
+ const realPath = resolved.path;
1374
+ if (!realPath) { log.error('ws', `工作区「${label}」没有可用路径。`); return { id: ws.id, title: label, path: null, error: 'no-path' }; }
1375
+ if (resolved.source === 'remapped') log.info('ws', `工作区「${label}」记录路径 ${resolved.recorded} 不存在,已按本机主目录调整为 ${realPath}`);
1376
+ try { fs.mkdirSync(realPath, { recursive: true }); }
1377
+ catch (e) { log.error('ws', `工作区「${label}」目录不可用:${e?.message || e}`); return { id: ws.id, title: label, path: realPath, error: 'mkdir' }; }
1378
+ const mirror = await this.ensureWorkspaceMirror(ws.id, realPath, cfg);
1379
+ if (!mirror.ok) { log.error('ws', `工作区「${label}」:${mirror.error}`); return { id: ws.id, title: label, path: realPath, error: mirror.error }; }
1380
+ const { gitdir, branch } = mirror;
1381
+ this.ensureWorkspaceIgnoreFile(realPath, cfg);
1382
+ this.ensureWorkspaceIgnoreFile(realPath, cfg);
1383
+ log.step('ws', `工作区「${label}」:暂存项目文件`);
1384
+ await this.gitW(gitdir, realPath, ['add', '-A'], { timeoutMs: 600000 });
1385
+ const st = await this.gitW(gitdir, realPath, ['status', '--porcelain']);
1386
+ const changed = this.ok(st) ? st.out.split('\n').filter(Boolean) : [];
1387
+ let committed = false;
1388
+ if (changed.length) {
1389
+ const msg = `${cfg.commitMessage}: workspace ${label} (${nowStamp()})`;
1390
+ const cm = await this.gitW(gitdir, realPath, ['commit', '-m', msg]);
1391
+ if (!this.ok(cm)) {
1392
+ const err = `提交失败:${clip(cm.err || cm.out)}`;
1393
+ log.error('ws', `工作区「${label}」${err}`);
1394
+ return { id: ws.id, title: label, path: realPath, error: err, changed: changed.length };
1395
+ }
1396
+ committed = true;
1397
+ log.info('ws', `工作区「${label}」已提交 ${changed.length} 项改动。`);
1398
+ result.commits.push({ repo: `ws:${ws.id}`, message: msg });
1399
+ } else {
1400
+ log.info('ws', `工作区「${label}」无未提交的改动。`);
1401
+ }
1402
+ const fetch = await this.gitW(gitdir, realPath, ['fetch', 'origin', '--prune', '--progress'], {
1403
+ timeoutMs: 900000,
1404
+ onStderr: (d) => logTransfer(log, 'ws-fetch', d),
1405
+ });
1406
+ if (!this.ok(fetch)) {
1407
+ const probe = await this.gitW(gitdir, realPath, ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], { timeoutMs: 120000 });
1408
+ const remoteExists = this.ok(probe) && Boolean(probe.out.trim());
1409
+ if (remoteExists || !this.ok(probe)) {
1410
+ log.error('ws', `工作区「${label}」读取远端失败:${clip(fetch.err || fetch.code)}`);
1411
+ return { id: ws.id, title: label, path: realPath, error: 'fetch', committed };
1412
+ }
1413
+ log.info('ws', `工作区「${label}」远端尚无该分支,执行首次上传。`);
1414
+ }
1415
+ const localHead = await this.gitW(gitdir, realPath, ['rev-parse', '--verify', 'HEAD']);
1416
+ if (!this.ok(localHead)) {
1417
+ log.info('ws', `工作区「${label}」无可提交内容,跳过(目标机器会自动创建该目录)。`);
1418
+ return { id: ws.id, title: label, path: realPath, empty: true, committed };
1419
+ }
1420
+ const ref = `refs/remotes/origin/${branch}`;
1421
+ const remoteHead = await this.gitW(gitdir, realPath, ['rev-parse', '--verify', ref]);
1422
+
1423
+ // 远端还没有这个分支 → 首次上传,直接推本地历史
1424
+ if (!this.ok(remoteHead)) {
1425
+ const first = await this.gitW(gitdir, realPath, ['push', '-u', 'origin', `HEAD:refs/heads/${branch}`, '--progress'], {
1426
+ timeoutMs: 1800000,
1427
+ onStderr: (d) => logTransfer(log, 'ws-push', d),
1428
+ });
1429
+ if (!this.ok(first)) {
1430
+ const err = `首次上传失败:${clip(first.err || first.code)}`;
1431
+ log.error('ws', `工作区「${label}」${err}`);
1432
+ return { id: ws.id, title: label, path: realPath, error: err, committed };
1433
+ }
1434
+ log.done('ws', `工作区「${label}」已首次推送至 ws/${ws.id}。`);
1435
+ return { id: ws.id, title: label, path: realPath, pushed: true, committed, branch };
1436
+ }
1437
+
1438
+ // 已有远端 → 走和主数据一样的「交付提交」:本机项目文件不被改动
1439
+ const ctxWs = {
1440
+ repo: `ws:${ws.id}`,
1441
+ g: (args, opts) => this.gitW(gitdir, realPath, args, opts),
1442
+ ref,
1443
+ branch,
1444
+ kindLabel: `工作区「${label}」`,
1445
+ label,
1446
+ ignore: null,
1447
+ };
1448
+ const build = await this.pushDelivery(run, cfg, store, ctxWs);
1449
+ if (!build.ok) {
1450
+ log.error('ws', `工作区「${label}」${build.error || '构造交付提交失败'}`);
1451
+ return { id: ws.id, title: label, path: realPath, error: build.error || 'deliver', committed };
1452
+ }
1453
+ await this.recordHeldUnits(run, store, build, ctxWs);
1454
+ if (!build.delivered?.length) {
1455
+ log.info('ws', `工作区「${label}」无可交付的新改动。`);
1456
+ return { id: ws.id, title: label, path: realPath, upToDate: true, committed };
1457
+ }
1458
+ log.done('ws', `工作区「${label}」已交付 ${build.delivered.length} 项改动到 ws/${ws.id}(本机文件未改动)。`);
1459
+ return {
1460
+ id: ws.id, title: label, path: realPath, pushed: true, committed, branch,
1461
+ delivered: build.delivered.length,
1462
+ held: build.held.length,
1463
+ };
1464
+ }
1465
+
1466
+ /* ================= pull ================= */
1467
+
1468
+ async pull(run, opts) {
1469
+ const log = run.progress;
1470
+ const cfg = this._cfg;
1471
+ const result = { direction: 'pull', scope: opts.scope, applied: [], merged: [], conflicts: [], skipped: [], workspaces: [], pendingId: null };
1472
+ const store = this.newPendingStore(run);
1473
+
1474
+ log.step('repo', '检查本地仓库与远端配置');
1475
+ const repo = await this.ensureRepo(cfg);
1476
+ if (!repo.ok) { result.error = repo.error; return result; }
1477
+ if (!cfg.remote) { result.error = '尚未配置远端仓库,无法取回。请先执行一次推送或填写仓库地址。'; log.error('repo', result.error); return result; }
1478
+
1479
+ log.step('fetch', `读取远端 ${cfg.branch} 分支`);
1480
+ const fetched = await this.fetchMain(run, cfg);
1481
+ if (!fetched.ok) {
1482
+ result.error = fetched.error;
1483
+ log.error('fetch', result.error);
1484
+ return result;
1485
+ }
1486
+ const tree = await this.remoteTree(cfg.branch);
1487
+ if (!tree.exists) { result.error = `远端尚无 ${cfg.branch} 分支数据,请先在本机执行一次推送。`; log.error('fetch', result.error); return result; }
1488
+ log.info('fetch', `远端 ${cfg.branch} = ${tree.head.slice(0, 7)},共 ${tree.files.size} 个文件。`);
1489
+
1490
+ const localHead = await this.git(['rev-parse', '--verify', 'HEAD']);
1491
+ const localHeadSha = this.ok(localHead) ? localHead.out.trim() : null;
1492
+ result.remoteHead = tree.head;
1493
+ result.localHeadBefore = localHeadSha;
1494
+
1495
+ // 取回永远是「把远端并入本机」:git 的分支是整份快照,没法只前进一个文件。
1496
+ // scope 在这里只影响报告口径与工作区目标,主数据的处理是同一条路。
1497
+ await this.pullAll(run, cfg, tree, result, store, opts);
1498
+
1499
+ if (opts.includeWorkspace && !result.hardError) {
1500
+ const targets = this.workspaceTargets(opts);
1501
+ if (!targets.length) log.info('ws', '无匹配工作区。');
1502
+ else {
1503
+ log.step('ws', `开始取回 ${targets.length} 个工作区`);
1504
+ for (const ws of targets) {
1505
+ const r = await this.pullWorkspace(run, cfg, ws, store);
1506
+ result.workspaces.push(r);
1507
+ }
1508
+ }
1509
+ } else {
1510
+ log.info('ws', '本次不含工作区文件。');
1511
+ }
1512
+
1513
+ if (store.conflicts.length) {
1514
+ result.pendingId = this.savePending(store).id;
1515
+ result.conflicts = store.conflicts;
1516
+ result.error = `${store.conflicts.length} 项冲突待裁决,本机文件未改动。`;
1517
+ log.error('end', result.error);
1518
+ } else if (result.aligned && !result.hardError) {
1519
+ log.done('end', '本机已与远端对齐。');
1520
+ }
1521
+ return result;
1522
+ }
1523
+
1524
+
1525
+ /**
1526
+ * 取回:逐个单元决定,然后做一次真实的合并,但**保护住不能自动覆盖的单元**。
1527
+ *
1528
+ * 不能动的单元(保护 = 合并后恢复本机版本并登记待裁决):
1529
+ * - 本机忽略的会话(含它的投影缓存)
1530
+ * - 已经有未裁决冲突的路径
1531
+ * - A11 远端删除 / A12 删除+修改 / A13 修改+删除 —— 删除类一律不自动生效
1532
+ * - A5 真冲突(会话日志分叉、文本重叠、二进制两边都改)
1533
+ * 例外:投影缓存是派生数据,两边都改时直接取本机。
1534
+ */
1535
+ async pullAll(run, cfg, tree, result, store, opts = {}) {
1536
+ const log = run.progress;
1537
+ const ref = `refs/remotes/origin/${cfg.branch}`;
1538
+ const g = (args, o) => this.git(args, o);
1539
+
1540
+ await this.snapshotLocalState(run, cfg, { repo: 'main', cwd: this.home, kindLabel: '主数据' });
1541
+
1542
+ const pendingSet = this.pendingPathSet('main');
1543
+ const ctx = {
1544
+ repo: 'main', g, ref, branch: cfg.branch, kindLabel: '主数据', label: '主数据',
1545
+ ignore: (p) => this.pathIsMuted(p, 'main', pendingSet),
1546
+ };
1547
+ const cls = await this.classifyUnits(run, cfg, ctx);
1548
+ if (!cls.ok || cls.noRemote) { result.error = '远端尚无可用分支。'; return; }
1549
+ if (cls.noLocal) { result.error = '本地仓库尚未初始化,请先执行一次推送。'; return; }
1550
+
1551
+ const protect = new Set(); // 合并后要恢复本机版本的路径
1552
+ const holds = []; // 要登记成待裁决的单元
1553
+ const contentMerges = []; // 需要写回内容合并结果的单元(A4)
1554
+ const cacheOurs = []; // 派生缓存:直接取本机
1555
+
1556
+ for (const u of cls.units) {
1557
+ if (u.state === 'IGNORED') { protect.add(u.path); continue; }
1558
+ if (u.state === 'A11' || u.state === 'A12' || u.state === 'A13') {
1559
+ const reason = u.state === 'A11' ? 'remote-deleted' : u.state === 'A12' ? 'delete-modify' : 'modify-delete';
1560
+ const remembered = this.unitDecision(`main:${u.path}`);
1561
+ if (remembered) {
1562
+ // 已经为这个路径做过决定,不再重复问
1563
+ protect.add(u.path);
1564
+ if (remembered.choice === 'delete') {
1565
+ protect.delete(u.path);
1566
+ log.info('pull', `${u.path}:按已有决定,本机同时删除。`);
1567
+ } else {
1568
+ log.info('pull', `${u.path}:按已有决定,本机保留。`);
1569
+ }
1570
+ continue;
1571
+ }
1572
+ protect.add(u.path);
1573
+ holds.push({ unit: u, reason });
1574
+ continue;
1575
+ }
1576
+ if (u.state === 'A5') {
1577
+ if (u.kind === 'cache') { cacheOurs.push(u); continue; }
1578
+ protect.add(u.path);
1579
+ holds.push({ unit: u, reason: u.kind === 'binary' ? 'binary-both-changed' : 'text-overlap' });
1580
+ continue;
1581
+ }
1582
+ if (u.state === 'A4' || u.state === 'A8') {
1583
+ const m = u.kind === 'cache' ? { ok: true, content: null } : await this.mergeUnitContent(g, u, u.kind);
1584
+ if (m.ok && m.content) { contentMerges.push({ unit: u, content: m.content }); continue; }
1585
+ if (u.kind === 'cache') { cacheOurs.push(u); continue; }
1586
+ protect.add(u.path);
1587
+ holds.push({ unit: u, reason: m.reason || 'divergent-session-history' });
1588
+ continue;
1589
+ }
1590
+ // A1 / A2 / A3 / A6 / A7 / A9 / A10:交给 git(A2/A6/A9 本机侧本来就不该被改)
1591
+ }
1592
+ if (!holds.length && !protect.size) result.aligned = true;
1593
+
1594
+ // 能纯快进就快进(干净、不留合并提交)
1595
+ const localHead = await g(['rev-parse', '--verify', 'HEAD']);
1596
+ const anc = await g(['merge-base', '--is-ancestor', localHead.out.trim(), cls.remoteHead]);
1597
+ if (this.ok(anc) && !protect.size && !contentMerges.length) {
1598
+ if (localHead.out.trim() === cls.remoteHead) { log.info('pull', '本机与远端一致。'); return; }
1599
+ log.step('pull', '快进至远端');
1600
+ const ff = await g(['merge', '--ff-only', ref], { timeoutMs: 900000 });
1601
+ if (this.ok(ff)) { log.done('pull', '已快进至远端。'); return; }
1602
+ log.warn('pull', `快进失败,改用逐条合并:${clip(ff.err || ff.code)}`);
1603
+ }
1604
+
1605
+ log.step('pull', '并入远端改动(逐条保护)');
1606
+ const mergeHeadPath = path.join(this.home, '.git', 'MERGE_HEAD');
1607
+ let mg = await g(['merge', '--no-commit', '--no-ff', ref], { timeoutMs: 1800000 });
1608
+ let merging = fs.existsSync(mergeHeadPath);
1609
+ if (!merging && !this.ok(mg)) {
1610
+ await g(['merge', '--no-commit', '--no-ff', '--allow-unrelated-histories', ref], { timeoutMs: 1800000 });
1611
+ merging = fs.existsSync(mergeHeadPath);
1612
+ }
1613
+ if (!merging && !this.ok(mg)) {
1614
+ result.hardError = true;
1615
+ result.error = `合并远端失败:${clip(mg.err || mg.code)}`;
1616
+ log.error('pull', result.error);
1617
+ return;
1618
+ }
1619
+
1620
+ // 内容合并(A4:纯追加的会话日志)
1621
+ for (const cm of contentMerges) {
1622
+ const abs = path.join(this.home, cm.unit.path);
1623
+ try {
1624
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
1625
+ fs.writeFileSync(abs, cm.content);
1626
+ await g(['add', '--', cm.unit.path]);
1627
+ result.merged.push({ path: cm.unit.path, mode: 'append' });
1628
+ log.info('pull', `${cm.unit.path}:双方均为纯追加,已自动合并。`);
1629
+ } catch (e) {
1630
+ log.error('pull', `${cm.unit.path}:写回合并结果失败 ${e?.message || e}`);
1631
+ }
1632
+ }
1633
+ // 派生缓存:取本机
1634
+ for (const u of cacheOurs) {
1635
+ await g(['checkout', 'HEAD', '--', u.path]);
1636
+ await g(['add', '--', u.path]);
1637
+ }
1638
+ // 保护:恢复本机版本并登记待裁决
1639
+ for (const path_ of protect) {
1640
+ const exists = await g(['cat-file', '-e', `HEAD:${path_}`]);
1641
+ if (this.ok(exists)) { await g(['checkout', 'HEAD', '--', path_]); await g(['add', '--', path_]); }
1642
+ else { await g(['rm', '--cached', '--quiet', '--ignore-unmatch', '--', path_]); }
1643
+ }
1644
+ // 兜底:git 自己产生的、我们没预判到的冲突(例如无共同历史下的 add/add)。
1645
+ // 一律恢复本机版本并登记待裁决 —— 绝不能把冲突标记提交进去。
1646
+ const leftU = await g(['diff', '--name-only', '--diff-filter=U']);
1647
+ const unmerged = this.ok(leftU) ? leftU.out.split('\n').map((x) => x.trim()).filter(Boolean) : [];
1648
+ for (const path_ of unmerged) {
1649
+ const exists = await g(['cat-file', '-e', `HEAD:${path_}`]);
1650
+ if (this.ok(exists)) { await g(['checkout', 'HEAD', '--', path_]); await g(['add', '--', path_]); }
1651
+ else { await g(['rm', '--cached', '--quiet', '--ignore-unmatch', '--', path_]); }
1652
+ if (protect.has(path_)) continue;
1653
+ protect.add(path_);
1654
+ const unit = cls.units.find((u) => u.path === path_) || { path: path_, kind: unitKindOf(path_), theirs: null, state: 'A8' };
1655
+ holds.push({ unit, reason: 'text-overlap' });
1656
+ log.warn('conflict', `${path_}:git 报告双方均已修改,已恢复本机版本并登记待裁决。`);
1657
+ }
1658
+
1659
+ for (const h of holds) {
1660
+ const meta = this.describeConflictPath(h.unit.path);
1661
+ const theirs = h.unit.theirs ? await this.readBlob(g, h.unit.theirs.sha) : null;
1662
+ await this.recordConflict(store, {
1663
+ repo: 'main', relPath: h.unit.path,
1664
+ kind: h.unit.kind === 'session-log' ? 'session-log' : 'file',
1665
+ reason: h.reason,
1666
+ theirsRef: theirs,
1667
+ sessionId: meta.sessionId, title: meta.title, state: h.unit.state,
1668
+ });
1669
+ log.warn('conflict', `${h.unit.path}:${holdReasonText(h.reason)};本机文件未改动,等待裁决。`);
1670
+ }
1671
+
1672
+ const cm = await g(['commit', '-m', `${cfg.commitMessage}: 取回远端改动 (${nowStamp()})`], { timeoutMs: 600000 });
1673
+ if (!this.ok(cm)) {
1674
+ log.warn('pull', `合并收尾提交失败:${clip(cm.err || cm.out)}`);
1675
+ } else {
1676
+ result.localHeadAfter = cm.out.trim();
1677
+ log.done('pull', '远端改动已并入本机。');
1678
+ }
1679
+ if (!holds.length) result.aligned = true;
1680
+ }
1681
+
1682
+
1683
+ async pullWorkspace(run, cfg, ws, store) {
1684
+ const log = run.progress;
1685
+ const label = ws.title || path.basename(ws.path) || ws.id;
1686
+ const resolved = this.resolveWorkspacePath(ws, cfg);
1687
+ const realPath = resolved.path;
1688
+ if (!realPath) { log.error('ws', `工作区「${label}」没有可用路径。`); return { id: ws.id, title: label, path: null, error: 'no-path' }; }
1689
+ if (resolved.source === 'remapped') log.info('ws', `工作区「${label}」记录路径 ${resolved.recorded} 不存在,已按本机主目录调整为 ${realPath}`);
1690
+ try { fs.mkdirSync(realPath, { recursive: true }); }
1691
+ catch (e) { return { id: ws.id, title: label, path: realPath, error: 'mkdir' }; }
1692
+ const mirror = await this.ensureWorkspaceMirror(ws.id, realPath, cfg);
1693
+ if (!mirror.ok) { log.error('ws', `工作区「${label}」:${mirror.error}`); return { id: ws.id, title: label, path: realPath, error: mirror.error }; }
1694
+ const { gitdir, branch } = mirror;
1695
+ const fetch = await this.gitW(gitdir, realPath, ['fetch', 'origin', '--prune', '--progress'], {
1696
+ timeoutMs: 1800000,
1697
+ onStderr: (d) => logTransfer(log, 'ws-fetch', d),
1698
+ });
1699
+ if (!this.ok(fetch)) {
1700
+ const probe = await this.gitW(gitdir, realPath, ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], { timeoutMs: 120000 });
1701
+ if (!this.ok(probe) || probe.out.trim()) {
1702
+ log.error('ws', `工作区「${label}」读取远端失败:${clip(fetch.err || fetch.code)}`);
1703
+ return { id: ws.id, title: label, path: realPath, error: 'fetch' };
1704
+ }
1705
+ log.info('ws', `工作区「${label}」远端尚无该分支,无需取回。`);
1706
+ return { id: ws.id, title: label, path: realPath, skipped: 'no-remote' };
1707
+ }
1708
+ const remoteHead = await this.gitW(gitdir, realPath, ['rev-parse', '--verify', `refs/remotes/origin/${branch}`]);
1709
+ if (!this.ok(remoteHead)) { log.info('ws', `工作区「${label}」远端无内容。`); return { id: ws.id, title: label, path: realPath, skipped: 'no-remote' }; }
1710
+ let localHead = await this.gitW(gitdir, realPath, ['rev-parse', '--verify', 'HEAD']);
1711
+ if (!this.ok(localHead)) {
1712
+ log.step('ws', `工作区「${label}」本机无内容,从远端整体取回`);
1713
+ const rs = await this.gitW(gitdir, realPath, ['reset', '--hard', `origin/${branch}`], { timeoutMs: 600000 });
1714
+ if (this.ok(rs)) { log.done('ws', `工作区「${label}」已取回至 ${realPath}`); return { id: ws.id, title: label, path: realPath, pulled: 'reset' }; }
1715
+ log.error('ws', `工作区「${label}」取回失败:${clip(rs.err || rs.code)}`);
1716
+ return { id: ws.id, title: label, path: realPath, error: 'reset' };
1717
+ }
1718
+ if (localHead.out.trim() === remoteHead.out.trim()) {
1719
+ log.info('ws', `工作区「${label}」已是最新。`);
1720
+ return { id: ws.id, title: label, path: realPath, upToDate: true };
1721
+ }
1722
+ // 本机项目文件有未提交改动时,ff/merge 都会被 git 拒绝;先落一个本地快照。
1723
+ // 快照会产生一个新提交,所以之后必须重新读取 localHead,否则后面的
1724
+ // 「是否可快进」判断会基于过期的 HEAD 做出错误决定。
1725
+ await this.snapshotLocalState(run, cfg, { repo: `ws:${ws.id}`, gitdir, worktree: realPath, kindLabel: `工作区「${label}」` });
1726
+ localHead = await this.gitW(gitdir, realPath, ['rev-parse', '--verify', 'HEAD']);
1727
+ const anc = await this.gitW(gitdir, realPath, ['merge-base', '--is-ancestor', localHead.out.trim(), remoteHead.out.trim()]);
1728
+ if (this.ok(anc)) {
1729
+ log.step('ws', `工作区「${label}」快进至远端`);
1730
+ const mg = await this.gitW(gitdir, realPath, ['merge', '--ff-only', `origin/${branch}`], { timeoutMs: 600000 });
1731
+ if (this.ok(mg)) { log.done('ws', `工作区「${label}」已快进至远端。`); return { id: ws.id, title: label, path: realPath, pulled: 'ff' }; }
1732
+ log.error('ws', `工作区「${label}」快进失败:${clip(mg.err || mg.code)}`);
1733
+ return { id: ws.id, title: label, path: realPath, error: 'ff' };
1734
+ }
1735
+ log.step('ws', `工作区「${label}」合并远端改动`);
1736
+ const merged = await this.mergeWithRemote(run, cfg, store, {
1737
+ repo: `ws:${ws.id}`, cwd: realPath, gitdir, worktree: realPath, label, kindLabel: `工作区「${label}」`,
1738
+ });
1739
+ if (merged === 'conflict') {
1740
+ log.error('ws', `工作区「${label}」存在冲突,已中止合并,本机文件未改动。`);
1741
+ return { id: ws.id, title: label, path: realPath, error: 'conflict' };
1742
+ }
1743
+ if (merged === 'error') return { id: ws.id, title: label, path: realPath, error: 'merge' };
1744
+ if (merged === 'uptodate') return { id: ws.id, title: label, path: realPath, upToDate: true };
1745
+ log.done('ws', `工作区「${label}」已与远端合并。`);
1746
+ return { id: ws.id, title: label, path: realPath, pulled: 'merge' };
1747
+ }
1748
+
1749
+ /* ================= 合并原语 ================= */
1750
+
1751
+ /**
1752
+ * 把本机工作树落成一个本地快照提交(只提交,不推送)。
1753
+ * git 在「合并会改到的文件有未提交改动」时会拒绝合并;而 DSH 几乎一直
1754
+ * 在写会话日志,所以取回前必须先固定本机状态。这一步本身不丢任何东西。
1755
+ * @returns {boolean} 是否产生了新提交
1756
+ */
1757
+ async snapshotLocalState(run, cfg, ctx) {
1758
+ const { repo, gitdir = null, worktree = null, kindLabel = '主数据' } = ctx;
1759
+ const g = (args, opts = {}) => (gitdir ? this.gitW(gitdir, worktree, args, opts) : this.git(args, opts));
1760
+ const st = await g(['status', '--porcelain']);
1761
+ if (!this.ok(st) || !st.out.trim()) return false;
1762
+ const count = st.out.split('\n').filter(Boolean).length;
1763
+ await g(['add', '-A'], { timeoutMs: 900000 });
1764
+ const cm = await g(['commit', '-m', `${cfg.commitMessage}: 取回前的本机快照 (${nowStamp()})`]);
1765
+ if (!this.ok(cm)) {
1766
+ run.progress.warn('snapshot', `${kindLabel} 本机改动无法提交为快照:${clip(cm.err || cm.out)}`);
1767
+ return false;
1768
+ }
1769
+ run.progress.info('snapshot', `${kindLabel} 已将本机 ${count} 项改动提交为本地快照(仅本地提交,不上传)。`);
1770
+ return true;
1771
+ }
1772
+
1773
+ /**
1774
+ * 把远端分支合并进当前仓库。
1775
+ *
1776
+ * 与旧插件的关键差别:**冲突不自动取本机**。
1777
+ * 这里先把远端版本抄一份留档,然后 `git merge --abort`,让工作树停在与操作前
1778
+ * 完全一致的状态,由用户逐项裁决。
1779
+ *
1780
+ * @returns {'ok'|'uptodate'|'conflict'|'error'}
1781
+ */
1782
+ async mergeWithRemote(run, cfg, store, ctx) {
1783
+ const log = run.progress;
1784
+ const { repo, gitdir = null, worktree = null, kindLabel = '主数据' } = ctx;
1785
+ const g = (args, opts = {}) => (gitdir ? this.gitW(gitdir, worktree, args, opts) : this.git(args, opts));
1786
+ const workdir = worktree || this.home;
1787
+ const ref = `origin/${this.branchOfRepo(repo, cfg)}`;
1788
+ let localHead = await g(['rev-parse', '--verify', 'HEAD']);
1789
+ const remoteHead = await g(['rev-parse', '--verify', ref]);
1790
+ if (!this.ok(remoteHead)) return 'uptodate';
1791
+ if (this.ok(localHead) && localHead.out.trim() === remoteHead.out.trim()) return 'uptodate';
1792
+
1793
+ if (!this.ok(localHead)) {
1794
+ // 本机还没有任何提交(换台电脑第一次取回):先把本机现状落成一个初始快照,
1795
+ // 这样本机已有的会话/项目文件不会在随后的合并里被当成「多余文件」抹掉。
1796
+ if (repo === 'main') await this.handOverGeneratedIgnore(g, ref, cfg, workdir, log);
1797
+ await g(['add', '-A'], { timeoutMs: 900000 });
1798
+ const cm = await g(['commit', '-m', `${cfg.commitMessage}: 加入同步前的本机快照 (${nowStamp()})`]);
1799
+ if (!this.ok(cm)) {
1800
+ // 连一个可提交的文件都没有(空目录)→ 直接采用远端内容
1801
+ log.info('merge', `${kindLabel} 本机为空,直接采用远端内容。`);
1802
+ const rs = await g(['reset', '--hard', ref], { timeoutMs: 1800000 });
1803
+ if (this.ok(rs)) return 'ok';
1804
+ log.error('merge', `${kindLabel} 采用远端内容失败:${clip(rs.err || rs.code)}`);
1805
+ return 'error';
1806
+ }
1807
+ log.info('merge', `${kindLabel} 本机现状已先提交为初始快照,再进行合并。`);
1808
+ localHead = await g(['rev-parse', '--verify', 'HEAD']);
1809
+ }
1810
+
1811
+ const base = await g(['merge-base', localHead.out.trim(), remoteHead.out.trim()]);
1812
+ const unrelated = !this.ok(base);
1813
+ if (unrelated) log.info('merge', `${kindLabel} 与本机没有共同历史,按「合并两段独立历史」处理(双方同路径不同内容会列为冲突)。`);
1814
+
1815
+ const mg = await g(['merge', '--no-edit', ...(unrelated ? ['--allow-unrelated-histories'] : []), ref], { timeoutMs: 1800000 });
1816
+ const mergeHeadExists = fs.existsSync(path.join(gitdir || path.join(this.home, '.git'), 'MERGE_HEAD'));
1817
+ if (this.ok(mg)) return 'ok';
1818
+ if (!mergeHeadExists) {
1819
+ log.error('merge', `${kindLabel} 合并失败:${clip(mg.err || mg.out || mg.code)}`);
1820
+ return 'error';
1821
+ }
1822
+ // —— 有冲突:留档远端版本,然后原样回退 ——
1823
+ const cf = await g(['diff', '--name-only', '--diff-filter=U']);
1824
+ const conflicted = this.ok(cf) ? cf.out.split('\n').map((l) => l.trim()).filter(Boolean) : [];
1825
+ log.warn('merge', `${kindLabel} 有 ${conflicted.length} 项冲突,正在逐项处理…`);
1826
+ // 会话投影缓存是 DSH 自己会重建的派生数据,冲突时保留本机即可 —— 让它上升到
1827
+ // 「需要你裁决」只会制造噪音(旧的 dsh-chatsync 也是这么处理的)。
1828
+ const remaining = [];
1829
+ for (const rel of conflicted) {
1830
+ if (!rel || rel.includes('\0')) continue;
1831
+ if (isRegenerableCache(rel)) {
1832
+ await g(['checkout', '--ours', '--', rel]);
1833
+ await g(['add', '--', rel]);
1834
+ log.info('merge', ` ${kindLabel} ${rel}:派生缓存,采用本机版本(DSH 会重建)。`);
1835
+ continue;
1836
+ }
1837
+ remaining.push(rel);
1838
+ }
1839
+ if (!remaining.length) {
1840
+ const left = await g(['diff', '--name-only', '--diff-filter=U']);
1841
+ if (this.ok(left) && !left.out.trim()) {
1842
+ const cm = await g(['commit', '-m', `${cfg.commitMessage}: 合并远端(派生缓存采用本机版本) (${nowStamp()})`], { timeoutMs: 600000 });
1843
+ if (this.ok(cm)) { log.done('merge', `${kindLabel} 已合并远端改动(只有可再生缓存冲突,已自动取本机)。`); return 'ok'; }
1844
+ }
1845
+ }
1846
+ for (const rel of remaining) {
1847
+ const meta = this.describeConflictPath(rel);
1848
+ const theirs = await g(['show', `MERGE_HEAD:${rel}`], { binary: true, timeoutMs: 300000 });
1849
+ await this.recordConflict(store, {
1850
+ repo,
1851
+ relPath: rel,
1852
+ kind: meta.kind,
1853
+ reason: meta.kind === 'session-log' ? 'divergent-session-history' : 'text-overlap',
1854
+ baseRef: null,
1855
+ theirsRef: this.ok(theirs) ? theirs.out : null,
1856
+ localRef: null,
1857
+ sessionId: meta.sessionId,
1858
+ title: meta.title,
1859
+ workspaceLabel: ctx.label || null,
1860
+ });
1861
+ log.warn('conflict', ` ${kindLabel} ${rel}${meta.title ? `(${meta.title})` : ''}:双方均已修改,需裁决。`);
1862
+ }
1863
+ const abort = await g(['merge', '--abort'], { timeoutMs: 300000 });
1864
+ if (!this.ok(abort)) log.error('merge', `回退合并失败:${clip(abort.err || abort.code)};工作树可能仍处于合并中状态。`);
1865
+ else log.info('merge', `${kindLabel} 已回退到合并前状态,本机文件未改动。`);
1866
+ return 'conflict';
1867
+ }
1868
+
1869
+ /**
1870
+ * 空仓库第一次取回时,本机 `.gitignore` 是插件刚生成的那一份,而远端往往已有一份。
1871
+ * 两份不同就会在无共同历史的合并里变成一个纯粹的假冲突。
1872
+ * 只有当本机这份「确实是我们自己生成的、用户没改过」时,才让远端版本接管。
1873
+ */
1874
+ async handOverGeneratedIgnore(g, ref, cfg, workdir, log) {
1875
+ try {
1876
+ const has = await g(['cat-file', '-e', `${ref}:.gitignore`]);
1877
+ if (!this.ok(has)) return;
1878
+ const p = path.join(workdir, '.gitignore');
1879
+ if (!fs.existsSync(p)) return;
1880
+ const generated = BUILTIN_IGNORE + '\n' + (cfg.extraIgnore || []).join('\n') + '\n';
1881
+ if (fs.readFileSync(p, 'utf8') !== generated) return; // 用户手工改过 → 保留,让真实差异走冲突流程
1882
+ fs.unlinkSync(p);
1883
+ log.info('merge', '本机 .gitignore 由插件生成且远端已存在,已让远端版本接管(避免首次取回产生假冲突)。');
1884
+ } catch { /* 失败就按普通文件处理 */ }
1885
+ }
1886
+
1887
+ describeConflictPath(rel) {
1888
+ const m = SESSION_LOG_PATH_RE.exec(rel);
1889
+ if (m) {
1890
+ const id = sessionIdOfPath(rel);
1891
+ return { kind: 'session-log', sessionId: id, title: readSessionTitle(this.home, id) || '' };
1892
+ }
1893
+ return { kind: 'file', sessionId: null, title: null };
1894
+ }
1895
+
1896
+
1897
+ /** 某个路径在本机是否相对 HEAD 有改动(含未跟踪) */
1898
+ async pathIsLocallyChanged(rel) {
1899
+ const r = await this.git(['status', '--porcelain', '--', rel]);
1900
+ return this.ok(r) && Boolean(r.out.trim());
1901
+ }
1902
+
1903
+ /* ================= 冲突裁决 ================= */
1904
+
1905
+ /**
1906
+ * 应用用户对冲突的裁决。
1907
+ * @param {{pendingId:string, decisions:Array<{id:string, choice:'local'|'remote'|'both'}>}} input
1908
+ */
1909
+ async resolveConflicts(input) {
1910
+ const pending = this.listPending().find((p) => p.id === input.pendingId);
1911
+ if (!pending) return { ok: false, error: '未找到该待裁决记录(可能已处理或清理)。' };
1912
+ const decisions = new Map((input.decisions || []).map((d) => [d.id, d.choice]));
1913
+ // 允许部分裁决:只处理这次选了处理方式的那几项,其余原样留在待裁决记录里。
1914
+ const decided = pending.conflicts.filter((c) => decisions.has(c.id));
1915
+ const remaining = pending.conflicts.filter((c) => !decisions.has(c.id));
1916
+ if (!decided.length) {
1917
+ return { ok: false, needsChoice: true, error: '还没有选择任何一项的处理方式。' };
1918
+ }
1919
+ const run = new RunState({ runId: randomUUID(), direction: 'pull', scope: 'resolve', reason: 'resolve-conflicts' });
1920
+ this.currentRun = run;
1921
+ this._cfg = this.loadConfig();
1922
+ const log = run.progress;
1923
+ const cfg = this._cfg;
1924
+ const result = { resolved: [], both: [], errors: [] };
1925
+ try {
1926
+ // 按仓库分组处理
1927
+ const deliveryConflicts = decided.filter((c) => c.repo === 'main' && c.side === 'delivery');
1928
+ const mainConflicts = decided.filter((c) => c.repo === 'main' && c.side !== 'delivery');
1929
+ if (deliveryConflicts.length) {
1930
+ const sub = { ...pending, conflicts: deliveryConflicts };
1931
+ await this.resolveDeliveryUnits(run, cfg, sub, decisions, result, this.newPendingStore(run));
1932
+ }
1933
+ if (mainConflicts.length) {
1934
+ await this.resolveInRepo(run, cfg, pending, mainConflicts, decisions, result, { repo: 'main', cwd: this.home, kindLabel: '主数据' });
1935
+ }
1936
+ const wsGroups = new Map();
1937
+ for (const c of decided.filter((x) => x.repo !== 'main')) {
1938
+ if (!wsGroups.has(c.repo)) wsGroups.set(c.repo, []);
1939
+ wsGroups.get(c.repo).push(c);
1940
+ }
1941
+ for (const [repo, list] of wsGroups) {
1942
+ const wsId = repo.slice(3);
1943
+ const ws = this.workspaceRecords().find((w) => w.id === wsId);
1944
+ if (!ws) { result.errors.push(`${repo}: 工作区已不在登记表中,无法处理。`); continue; }
1945
+ // 必须走和同步时同一套路径解析:登记表里的路径可能是另一台电脑的绝对路径。
1946
+ const realPath = this.resolveWorkspacePath(ws, cfg).path;
1947
+ if (!realPath) { result.errors.push(`${repo}: 工作区无可用路径。`); continue; }
1948
+ const mirror = await this.ensureWorkspaceMirror(wsId, realPath, cfg);
1949
+ if (!mirror.ok) { result.errors.push(`${repo}: ${mirror.error}`); continue; }
1950
+ await this.resolveInRepo(run, cfg, pending, list, decisions, result, {
1951
+ repo, cwd: realPath, gitdir: mirror.gitdir, worktree: realPath,
1952
+ kindLabel: `工作区「${ws.title || path.basename(realPath)}」`,
1953
+ });
1954
+ }
1955
+ if (result.errors.length) return { ok: false, error: result.errors.join(';'), detail: result, log: log.all() };
1956
+ if (remaining.length) {
1957
+ const manifest = { ...pending, conflicts: remaining, id: pending.id };
1958
+ delete manifest.dir;
1959
+ fs.writeFileSync(path.join(pending.dir, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8');
1960
+ log.done('end', `已按裁决处理 ${decided.length} 项,剩余 ${remaining.length} 项待裁决。`);
1961
+ } else {
1962
+ this.clearPending(pending.id);
1963
+ log.done('end', `已按裁决处理 ${decided.length} 项冲突,全部完成。`);
1964
+ }
1965
+ return { ok: true, detail: result, remaining: remaining.length, log: log.all() };
1966
+ } catch (e) {
1967
+ return { ok: false, error: String(e?.message || e), log: run.progress.all() };
1968
+ } finally {
1969
+ run.running = false;
1970
+ run.finishedAt = Date.now();
1971
+ this.lastRun = run;
1972
+ }
1973
+ }
1974
+
1975
+ async resolveInRepo(run, cfg, pending, conflicts, decisions, result, ctx) {
1976
+ const log = run.progress;
1977
+ const { repo, gitdir = null, worktree = null, kindLabel } = ctx;
1978
+ const g = (args, opts = {}) => (gitdir ? this.gitW(gitdir, worktree, args, opts) : this.git(args, opts));
1979
+ const ref = `origin/${this.branchOfRepo(repo, cfg)}`;
1980
+
1981
+ // 重新走一次合并,让 git 重新产生这些冲突,然后按裁决逐个物化。
1982
+ // merge 要求工作树对「它要改的文件」没有未提交改动,所以先把本机现状落成快照;
1983
+ // 这一步也保证裁决完成后分支是真的和远端对齐了(提交会成为 merge 提交)。
1984
+ await this.snapshotLocalState(run, cfg, ctx);
1985
+ const localHead = await g(['rev-parse', '--verify', 'HEAD']);
1986
+ const remoteHead = await g(['rev-parse', '--verify', ref]);
1987
+ const mergeHeadPath = path.join(gitdir || path.join(this.home, '.git'), 'MERGE_HEAD');
1988
+ // 「合并进行中」的唯一判据是 MERGE_HEAD 存在。
1989
+ // `git merge` 返回 0 也可能是 "Already up to date."(本机已经包含远端),
1990
+ // 那种情况下并没有待提交的合并,不能按合并收尾去 commit,否则会撞上
1991
+ // 「无文件要提交,工作区干净」。
1992
+ let mergeStarted = false;
1993
+ if (this.ok(remoteHead) && (!this.ok(localHead) || localHead.out.trim() !== remoteHead.out.trim())) {
1994
+ const mg = await g(['merge', '--no-commit', '--no-ff', ref], { timeoutMs: 900000 });
1995
+ mergeStarted = fs.existsSync(mergeHeadPath);
1996
+ if (!mergeStarted && !this.ok(mg)) {
1997
+ await g(['merge', '--no-commit', '--no-ff', '--allow-unrelated-histories', ref], { timeoutMs: 900000 });
1998
+ mergeStarted = fs.existsSync(mergeHeadPath);
1999
+ }
2000
+ }
2001
+ if (!mergeStarted) {
2002
+ log.info('resolve', `${kindLabel} 本机历史已包含远端改动,无需额外的合并提交。`);
2003
+ }
2004
+ try {
2005
+ for (const c of conflicts) {
2006
+ const choice = decisions.get(c.id);
2007
+ const rel = c.path;
2008
+ const label = c.title ? `「${c.title}」` : rel;
2009
+ if (choice === 'local') {
2010
+ if (mergeStarted) { await g(['checkout', '--ours', '--', rel]); await g(['add', '--', rel]); }
2011
+ log.info('resolve', `${kindLabel} ${rel}${c.title ? ` ${label}` : ''}:保留本机版本。`);
2012
+ result.resolved.push({ id: c.id, choice });
2013
+ continue;
2014
+ }
2015
+ if (choice === 'remote') {
2016
+ if (mergeStarted) { await g(['checkout', '--theirs', '--', rel]); await g(['add', '--', rel]); }
2017
+ else if (c.theirsCopy) {
2018
+ const src = path.join(pending.dir, c.theirsCopy);
2019
+ const dest = path.join(this.home, ...(repo === 'main' ? [rel] : []));
2020
+ if (repo === 'main') { fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.copyFileSync(src, dest); await g(['add', '--', rel]); }
2021
+ else { const d2 = path.join(worktree, rel); fs.mkdirSync(path.dirname(d2), { recursive: true }); fs.copyFileSync(src, d2); await g(['add', '--', rel]); }
2022
+ }
2023
+ log.info('resolve', `${kindLabel} ${rel}${c.title ? ` ${label}` : ''}:采用远端版本。`);
2024
+ result.resolved.push({ id: c.id, choice });
2025
+ continue;
2026
+ }
2027
+ // both:两边都留
2028
+ if (c.kind === 'session-log' && c.sessionId) {
2029
+ const newId = newSessionId();
2030
+ if (mergeStarted) { await g(['checkout', '--ours', '--', rel]); await g(['add', '--', rel]); }
2031
+ let newPath = null;
2032
+ try {
2033
+ const theirsBuf = c.theirsCopy ? fs.readFileSync(path.join(pending.dir, c.theirsCopy)) : null;
2034
+ if (theirsBuf) newPath = duplicateSessionLog(this.home, theirsBuf, newId, path.basename(rel));
2035
+ } catch { newPath = null; }
2036
+ if (newPath) {
2037
+ const newRel = normRel(path.relative(this.home, newPath));
2038
+ await this.git(['add', '-f', '--', newRel]);
2039
+ await this.registerSessionInWorkspace(c.sessionId, newId);
2040
+ log.info('resolve', `${kindLabel} 会话 ${label}:本机版本保留,远端版本已导入为新会话 ${shortId(newId)}。`);
2041
+ result.both.push({ id: c.id, newSessionId: newId, newPath: newRel });
2042
+ } else {
2043
+ result.errors.push(`${rel}: 无法将远端会话导入为新会话(日志无法解析)。`);
2044
+ log.error('resolve', `${rel}: 无法导入远端会话,「两边都留」失败。`);
2045
+ }
2046
+ } else {
2047
+ if (mergeStarted) { await g(['checkout', '--ours', '--', rel]); await g(['add', '--', rel]); }
2048
+ const suffix = `.remote-${ts()}`;
2049
+ const copyRel = rel + suffix;
2050
+ const src = path.join(pending.dir, c.theirsCopy);
2051
+ const dest = repo === 'main' ? path.join(this.home, copyRel) : path.join(worktree, copyRel);
2052
+ try {
2053
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
2054
+ fs.copyFileSync(src, dest);
2055
+ await g(['add', '-f', '--', copyRel]);
2056
+ log.info('resolve', `${kindLabel} ${rel}:本机版本保留,远端版本另存为 ${copyRel}。`);
2057
+ result.both.push({ id: c.id, newPath: copyRel });
2058
+ } catch (e) {
2059
+ result.errors.push(`${rel}: 保存远端副本失败 ${e?.message || e}`);
2060
+ }
2061
+ }
2062
+ result.resolved.push({ id: c.id, choice });
2063
+ }
2064
+ // 兜底:这次没裁决、以及 git 报出的我没预判到的冲突,一律恢复本机版本。
2065
+ // 这样合并不但能收尾,这些单元也仍然「待裁决」—— 下次点「应用」继续处理。
2066
+ const leftU = await g(['diff', '--name-only', '--diff-filter=U']);
2067
+ const unmerged = this.ok(leftU) ? leftU.out.split('\n').map((x) => x.trim()).filter(Boolean) : [];
2068
+ for (const path_ of unmerged) {
2069
+ const exists = await g(['cat-file', '-e', `HEAD:${path_}`]);
2070
+ if (this.ok(exists)) { await g(['checkout', 'HEAD', '--', path_]); await g(['add', '--', path_]); }
2071
+ else { await g(['rm', '--cached', '--quiet', '--ignore-unmatch', '--', path_]); }
2072
+ }
2073
+
2074
+ if (mergeStarted) {
2075
+ // 合并不是由 git 自己收尾的(我们用了 --no-commit 才能逐项应用裁决),
2076
+ // 所以必须显式给提交信息,否则 git 会以「提交说明为空」中止。
2077
+ const cm = await g(['commit', '-m', `${cfg.commitMessage}: 合并远端改动并按裁决处理冲突 (${nowStamp()})`], { timeoutMs: 600000 });
2078
+ if (!this.ok(cm)) {
2079
+ const err = `提交合并结果失败:${clip(cm.err || cm.out)}`;
2080
+ result.errors.push(err);
2081
+ log.error('resolve', err);
2082
+ return;
2083
+ }
2084
+ log.done('resolve', `${kindLabel} 合并结果已提交。`);
2085
+ } else {
2086
+ await g(['add', '-A']);
2087
+ const st = await g(['status', '--porcelain']);
2088
+ if (this.ok(st) && st.out.trim()) {
2089
+ const cm = await g(['commit', '-m', `${cfg.commitMessage}: 按裁决应用取回结果 (${nowStamp()})`]);
2090
+ if (!this.ok(cm)) result.errors.push(`提交失败:${clip(cm.err || cm.out)}`);
2091
+ else log.done('resolve', `${kindLabel} 已提交裁决结果。`);
2092
+ }
2093
+ }
2094
+ } catch (e) {
2095
+ result.errors.push(String(e?.message || e));
2096
+ try {
2097
+ if (fs.existsSync(path.join(gitdir || path.join(this.home, '.git'), 'MERGE_HEAD'))) await g(['merge', '--abort']);
2098
+ } catch { /* ignore */ }
2099
+ }
2100
+ }
2101
+
2102
+ /** 把「两边都留」新建的会话登记到原会话所属工作区,避免它显示成未分组 */
2103
+ async registerSessionInWorkspace(oldSessionId, newSessionId) {
2104
+ const file = path.join(this.home, 'storages', 'workspace.json');
2105
+ const j = readJson(file);
2106
+ if (!j?.tables?.workspaces) return false;
2107
+ for (const w of Object.values(j.tables.workspaces)) {
2108
+ if (!w || !Array.isArray(w.sessionIds)) continue;
2109
+ if (!w.sessionIds.includes(oldSessionId)) continue;
2110
+ if (w.sessionIds.includes(newSessionId)) return true;
2111
+ const idx = w.sessionIds.indexOf(oldSessionId);
2112
+ w.sessionIds.splice(idx + 1, 0, newSessionId);
2113
+ try { fs.writeFileSync(file, JSON.stringify(j, null, 2), 'utf8'); } catch { return false; }
2114
+ return true;
2115
+ }
2116
+ return false;
2117
+ }
2118
+
2119
+ /**
2120
+ * 交付侧(推送)待裁决的收尾。
2121
+ * 用户的选择在这里被翻译成两种动作:
2122
+ * - 本端优先(local / delete)→ 记进 forceOurs,重新构造交付提交并推送
2123
+ * - 采用远端(remote / both)→ 把 main 的版本落到本机(both 时另存为新会话/副本)
2124
+ */
2125
+ async resolveDeliveryUnits(run, cfg, pending, decisions, result, store) {
2126
+ const log = run.progress;
2127
+ const g = (args, opts) => this.git(args, opts);
2128
+ const ref = `refs/remotes/origin/${cfg.branch}`;
2129
+ await this.fetchMain(run, cfg);
2130
+
2131
+ const forceOurs = new Map();
2132
+ const takeRemote = [];
2133
+ const remember = {};
2134
+
2135
+ for (const c of pending.conflicts) {
2136
+ const choice = decisions.get(c.id);
2137
+ const isDelete = ['delete-local', 'delete-modify', 'modify-delete', 'remote-deleted'].includes(c.reason);
2138
+ const key = `${c.repo}:${c.path}`;
2139
+
2140
+ if (isDelete) {
2141
+ // 删除类冲突:两种选择在不同状态下的含义不同,必须按 reason 精确执行
2142
+ remember[key] = choice;
2143
+ if (c.reason === 'delete-local') { // 本机删了、main 没动
2144
+ if (choice === 'delete') { forceOurs.set(c.path, 'delete'); log.info('resolve', `${c.path}:将删除同步至远端。`); }
2145
+ else log.info('resolve', `${c.path}:远端保持原样,本机继续不含该文件。`);
2146
+ } else if (c.reason === 'delete-modify') { // 本机删了、main 又改了
2147
+ if (choice === 'delete') { forceOurs.set(c.path, 'delete'); log.info('resolve', `${c.path}:保留删除,并同步至远端。`); }
2148
+ else { takeRemote.push({ c, both: false }); log.info('resolve', `${c.path}:采用远端版本。`); }
2149
+ } else if (c.reason === 'modify-delete') { // main 删了、本机又改了
2150
+ if (choice === 'keep') { forceOurs.set(c.path, 'ours'); log.info('resolve', `${c.path}:保留本机版本并推送至远端。`); }
2151
+ else { takeRemote.push({ c, both: false }); log.info('resolve', `${c.path}:接受删除,本机同时删除。`); }
2152
+ } else { // remote-deleted:main 删了、本机没动
2153
+ if (choice === 'delete') { takeRemote.push({ c, both: false }); log.info('resolve', `${c.path}:本机同时删除。`); }
2154
+ else log.info('resolve', `${c.path}:本机保留,远端保持已删除。`);
2155
+ }
2156
+ continue;
2157
+ }
2158
+
2159
+ if (choice === 'local') {
2160
+ // 「保留本机」= 把本机这一版交付给 main,**不是删除**
2161
+ forceOurs.set(c.path, 'ours');
2162
+ log.info('resolve', `${c.path}:按裁决将本机版本交付至远端。`);
2163
+ continue;
2164
+ }
2165
+ if (choice === 'remote') {
2166
+ takeRemote.push({ c, both: false });
2167
+ log.info('resolve', `${c.path}:采用远端版本覆盖本机。`);
2168
+ continue;
2169
+ }
2170
+ if (choice === 'both') {
2171
+ forceOurs.set(c.path, 'ours');
2172
+ takeRemote.push({ c, both: true });
2173
+ log.info('resolve', `${c.path}:本机版本交付至远端,同时将远端版本保留到本机。`);
2174
+ continue;
2175
+ }
2176
+ // keep:什么都不做(main 保持原样,本机保持原样)
2177
+ if (isDelete) remember[`${c.repo}:${c.path}`] = 'keep';
2178
+ }
2179
+
2180
+ // 采用远端:落到本机
2181
+ for (const { c, both } of takeRemote) {
2182
+ const theirs = await g(['cat-file', '-e', `${ref}:${c.path}`]);
2183
+ const exists = this.ok(theirs);
2184
+ if (both && c.kind === 'session-log' && c.sessionId && exists) {
2185
+ const blob = await this.readBlob(g, (await g(['rev-parse', `${ref}:${c.path}`])).out.trim());
2186
+ const newId = newSessionId();
2187
+ const newPath = blob ? duplicateSessionLog(this.home, blob, newId, path.basename(c.path)) : null;
2188
+ if (newPath) {
2189
+ const rel = normRel(path.relative(this.home, newPath));
2190
+ await g(['add', '-f', '--', rel]);
2191
+ await this.registerSessionInWorkspace(c.sessionId, newId);
2192
+ result.both.push({ id: c.id, newSessionId: newId, newPath: rel });
2193
+ log.info('resolve', `远端那一支已导入为新会话 ${newId}。`);
2194
+ }
2195
+ continue;
2196
+ }
2197
+ if (exists) {
2198
+ await g(['checkout', ref, '--', c.path]);
2199
+ log.info('resolve', `已把远端的 ${c.path} 写入本机。`);
2200
+ } else {
2201
+ try { fs.rmSync(path.join(this.home, c.path), { force: true }); } catch { /* ignore */ }
2202
+ await g(['rm', '--cached', '--quiet', '--ignore-unmatch', '--', c.path]);
2203
+ log.info('resolve', `远端已删除 ${c.path},本机也删除。`);
2204
+ }
2205
+ result.resolved.push({ id: c.id, choice: decisions.get(c.id) });
2206
+ }
2207
+
2208
+ const st = await g(['status', '--porcelain']);
2209
+ if (this.ok(st) && st.out.trim()) {
2210
+ const cm = await g(['commit', '-m', `${cfg.commitMessage}: 按裁决应用取回结果 (${nowStamp()})`], { timeoutMs: 600000 });
2211
+ if (this.ok(cm)) log.done('resolve', '本机侧的裁决结果已提交。');
2212
+ else log.warn('resolve', `提交失败:${clip(cm.err || cm.out)}`);
2213
+ }
2214
+
2215
+ for (const [k, v] of Object.entries(remember)) this.setUnitDecision(k, v);
2216
+
2217
+ // 本端优先的那些:重新构造交付提交并推送
2218
+ if (forceOurs.size) {
2219
+ log.step('resolve', '将「本机优先」的条目重新交付至远端');
2220
+ const ctx = {
2221
+ repo: 'main', g, ref, branch: cfg.branch, kindLabel: '主数据', label: '主数据',
2222
+ forceOurs,
2223
+ ignore: (p) => this.pathIsMuted(p, 'main') && !forceOurs.has(p),
2224
+ };
2225
+ const build = await this.pushDelivery(run, cfg, store, ctx);
2226
+ if (!build.ok) { result.errors.push(build.error || '重新交付失败'); return; }
2227
+ if (build.delivered?.length) result.resolved.push({ delivered: build.delivered });
2228
+ const stillHeld = (build.held || []).length;
2229
+ if (stillHeld) result.errors.push(`还有 ${stillHeld} 项在重新交付时仍无法自动处理`);
2230
+ }
2231
+ }
2232
+
2233
+ /* ================= 逐单元状态与重新对齐 ================= */
2234
+
2235
+ /** 逐单元状态表(界面用):哪些会话仅本机改了 / 仅远端改了 / 两边都改 / 已忽略 */
2236
+ async unitStates(limit = 600) {
2237
+ const cfg = this.loadConfig();
2238
+ const ref = `refs/remotes/origin/${cfg.branch}`;
2239
+ const g = (args, opts) => this.git(args, opts);
2240
+ const noop = { info() {}, warn() {}, step() {}, error() {}, done() {} };
2241
+ const fakeRun = { progress: noop };
2242
+ const pendingSet = this.pendingPathSet('main');
2243
+ const cls = await this.classifyUnits(fakeRun, cfg, {
2244
+ repo: 'main', g, ref, kindLabel: '主数据',
2245
+ ignore: (p) => this.pathIsMuted(p, 'main', pendingSet),
2246
+ });
2247
+ if (!cls.ok || cls.noRemote || cls.noLocal) {
2248
+ return { ok: false, unrelated: false, units: [], total: 0, reason: cls.noRemote ? 'no-remote' : 'no-local' };
2249
+ }
2250
+ const out = [];
2251
+ for (const u of cls.units) {
2252
+ const sid = isSessionLogPath(u.path) ? sessionIdOfPath(u.path) : sessionCacheIdOf(u.path);
2253
+ out.push({
2254
+ path: u.path,
2255
+ state: u.state,
2256
+ stateLabel: STATE_LABEL[u.state] || u.state,
2257
+ kind: u.kind,
2258
+ sessionId: sid || null,
2259
+ title: sid ? (readSessionTitle(this.home, sid) || '') : '',
2260
+ muted: u.state === 'IGNORED',
2261
+ pending: pendingSet.has(u.path),
2262
+ needsDecision: ['A5', 'A8', 'A9', 'A11', 'A12', 'A13'].includes(u.state),
2263
+ });
2264
+ }
2265
+ const rank = { A5: 0, A8: 0, A9: 0, A11: 0, A12: 0, A13: 0, A4: 1, A2: 2, A6: 2, A3: 3, A7: 3, A1: 4, IGNORED: 5 };
2266
+ out.sort((a, b) => (rank[a.state] ?? 9) - (rank[b.state] ?? 9) || a.path.localeCompare(b.path));
2267
+ return { ok: true, unrelated: cls.unrelated === true, base: cls.base, units: out.slice(0, limit), total: out.length };
2268
+ }
2269
+
2270
+ /**
2271
+ * 显式强制覆盖单个单元。
2272
+ * mode='pull' → 无条件用 main 的版本覆盖本机这一份(不看本机改动)
2273
+ * mode='push' → 无条件把本机这一份交付给 main(不看 main 的改动)
2274
+ * 该路径如果还有未裁决的冲突,先要求去裁决 —— 免得两种决定打架。
2275
+ */
2276
+ async forceUnit(relPath, mode) {
2277
+ const p = normRel(relPath);
2278
+ if (!p) return { ok: false, error: '缺少路径。' };
2279
+ if (mode !== 'pull' && mode !== 'push') return { ok: false, error: 'mode 只能是 pull 或 push。' };
2280
+ // 「强制覆盖」本身就是一种显式裁决:先把这个路径从待裁决记录里摘掉,它不再需要另行决定。
2281
+ const cleared = this.clearPendingForPath('main', p);
2282
+ const cfg = this.loadConfig();
2283
+ const ref = `refs/remotes/origin/${cfg.branch}`;
2284
+ const g = (args, opts) => this.git(args, opts);
2285
+ const log = new ProgressLog();
2286
+ const run = { progress: log };
2287
+ const fetched = await this.fetchMain(run, cfg);
2288
+ if (!fetched.ok) return { ok: false, error: fetched.error, log: log.all() };
2289
+
2290
+ if (mode === 'pull') {
2291
+ const exists = await g(['cat-file', '-e', `${ref}:${p}`]);
2292
+ if (this.ok(exists)) {
2293
+ const co = await g(['checkout', ref, '--', p]);
2294
+ if (!this.ok(co)) return { ok: false, error: `采用远端失败:${clip(co.err || co.code)}`, log: log.all() };
2295
+ } else {
2296
+ try { fs.rmSync(path.join(this.home, p), { force: true }); } catch { /* ignore */ }
2297
+ await g(['rm', '--cached', '--quiet', '--ignore-unmatch', '--', p]);
2298
+ }
2299
+ await g(['add', '-A', '--', p]);
2300
+ const st = await g(['status', '--porcelain', '--', p]);
2301
+ if (this.ok(st) && st.out.trim()) {
2302
+ await g(['commit', '-m', `${cfg.commitMessage}: 强制采用远端版本 ${p} (${nowStamp()})`, '--', p], { timeoutMs: 300000 });
2303
+ }
2304
+ log.done('force', `${p}:已用远端版本覆盖本机。`);
2305
+ return { ok: true, mode, path: p, clearedPending: cleared, log: log.all() };
2306
+ }
2307
+
2308
+ // 强制交付前先把这一份的本机改动提交进本端历史 —— 否则它不会出现在
2309
+ // 「本机相对 base 的改动」里,交付树也就无从叠加。
2310
+ await g(['add', '-A', '--', p]);
2311
+ const st0 = await g(['status', '--porcelain', '--', p]);
2312
+ if (this.ok(st0) && st0.out.trim()) {
2313
+ await g(['commit', '-m', `${cfg.commitMessage}: 强制交付前的本机快照 ${p} (${nowStamp()})`, '--', p], { timeoutMs: 300000 });
2314
+ }
2315
+
2316
+ const store = this.newPendingStore(run);
2317
+ const forceOurs = new Map([[p, 'ours']]);
2318
+ const build = await this.pushDelivery(run, cfg, store, {
2319
+ repo: 'main', g, ref, branch: cfg.branch, kindLabel: '主数据', label: '主数据',
2320
+ forceOurs,
2321
+ ignore: (x) => this.pathIsMuted(x, 'main') && !forceOurs.has(x),
2322
+ });
2323
+ if (!build.ok) return { ok: false, error: build.error || '交付失败', log: log.all() };
2324
+ await this.recordHeldUnits(run, store, build, { repo: 'main', g, label: '主数据' });
2325
+ if (!build.delivered?.length) return { ok: false, error: '无可交付内容(本机可能与远端一致)。', log: log.all() };
2326
+ log.done('force', `${p}:已用本机版本覆盖远端。`);
2327
+ return { ok: true, mode, path: p, delivered: build.delivered, clearedPending: cleared, log: log.all() };
2328
+ }
2329
+
2330
+ /**
2331
+ * 重新对齐:把本机拉到与远端一致。
2332
+ * 只做纯快进 —— 永远安全,不产生合并提交,也不会丢东西。
2333
+ */
2334
+ async alignToRemote() {
2335
+ const cfg = this.loadConfig();
2336
+ const g = (args, opts) => this.git(args, opts);
2337
+ const ref = `refs/remotes/origin/${cfg.branch}`;
2338
+ const p = new ProgressLog();
2339
+ const fakeRun = { progress: p };
2340
+ const fetched = await this.fetchMain(fakeRun, cfg);
2341
+ if (!fetched.ok) return { ok: false, error: fetched.error, log: p.all() };
2342
+ const local = await g(['rev-parse', '--verify', 'HEAD']);
2343
+ const remote = await g(['rev-parse', '--verify', ref]);
2344
+ if (!this.ok(remote)) return { ok: false, error: '远端还没有可对齐的分支。', log: p.all() };
2345
+ if (!this.ok(local)) return { ok: false, error: '本机还没有任何提交。', log: p.all() };
2346
+ if (local.out.trim() === remote.out.trim()) return { ok: true, upToDate: true, log: p.all() };
2347
+
2348
+ const remoteInLocal = await g(['merge-base', '--is-ancestor', remote.out.trim(), local.out.trim()]);
2349
+ if (this.ok(remoteInLocal)) {
2350
+ const counts = await g(['rev-list', '--left-right', '--count', `${ref}...HEAD`]);
2351
+ const [behind, ahead] = this.ok(counts) ? counts.out.trim().split(/\s+/).map(Number) : [0, 0];
2352
+ return { ok: true, upToDate: true, ahead: ahead || 0, behind: behind || 0, log: p.all() };
2353
+ }
2354
+ const clean = await g(['status', '--porcelain']);
2355
+ if (this.ok(clean) && clean.out.trim()) {
2356
+ await this.snapshotLocalState(fakeRun, cfg, { repo: 'main', cwd: this.home, kindLabel: '主数据' });
2357
+ }
2358
+ const ff = await g(['merge', '--ff-only', ref], { timeoutMs: 900000 });
2359
+ if (!this.ok(ff)) return { ok: false, error: `纯快进失败(本机有远端没有的提交):${clip(ff.err || ff.code)}`, log: p.all() };
2360
+ return { ok: true, aligned: true, log: p.all() };
2361
+ }
2362
+
2363
+ /* ================= 状态 ================= */
2364
+
2365
+ async status() {
2366
+ const cfg = this.loadConfig();
2367
+ const out = {
2368
+ home: this.home,
2369
+ configPath: this.configPath(),
2370
+ remote: cfg.remote || '',
2371
+ branch: cfg.branch,
2372
+ proxy: cfg.proxy || '',
2373
+ autoRepo: cfg.autoRepo !== false,
2374
+ repoName: cfg.repoName,
2375
+ workspaceBase: cfg.workspaceBase || '',
2376
+ showSidebarButton: cfg.showSidebarButton !== false,
2377
+ gitMissing: this.gitMissing,
2378
+ ghAvailable: await this.ghAvailable(),
2379
+ running: this.busy,
2380
+ lastRun: this.lastRun ? this.lastRun.summary() : null,
2381
+ current: this.currentRun && this.currentRun.running ? this.currentRun.summary() : null,
2382
+ pending: this.listPending().map((p) => ({ id: p.id, createdAt: p.createdAt, count: p.conflicts.length, conflicts: p.conflicts })),
2383
+ repo: null,
2384
+ sessions: null,
2385
+ workspaces: [],
2386
+ };
2387
+ try {
2388
+ const repoExists = fs.existsSync(path.join(this.home, '.git'));
2389
+ out.repo = { exists: repoExists, head: null, remoteHead: null, ahead: 0, behind: 0, dirty: 0, initialized: repoExists };
2390
+ if (repoExists) {
2391
+ const head = await this.git(['rev-parse', '--short', 'HEAD']);
2392
+ out.repo.head = this.ok(head) ? head.out.trim() : null;
2393
+ const st = await this.git(['status', '--porcelain']);
2394
+ out.repo.dirty = this.ok(st) ? st.out.split('\n').filter(Boolean).length : 0;
2395
+ const remoteHead = await this.git(['rev-parse', '--short', `refs/remotes/origin/${cfg.branch}`]);
2396
+ out.repo.remoteHead = this.ok(remoteHead) ? remoteHead.out.trim() : null;
2397
+ if (out.repo.head && out.repo.remoteHead) {
2398
+ const counts = await this.git(['rev-list', '--left-right', '--count', `refs/remotes/origin/${cfg.branch}...HEAD`]);
2399
+ if (this.ok(counts)) {
2400
+ const [behind, ahead] = counts.out.trim().split(/\s+/).map((x) => Number(x) || 0);
2401
+ out.repo.behind = behind;
2402
+ out.repo.ahead = ahead;
2403
+ }
2404
+ }
2405
+ }
2406
+ const local = this.localSessions();
2407
+ const owner = this.sessionToWorkspace();
2408
+ const shas = await this.localBlobShas(local.map((s) => s.relLog));
2409
+ const remoteFiles = out.repo.remoteHead ? (await this.remoteTree(cfg.branch)).files : new Map();
2410
+ out.sessions = local.map((s) => {
2411
+ const sha = shas.get(s.relLog) || null;
2412
+ const remoteSha = remoteFiles.get(s.relLog) || null;
2413
+ let state = 'local-only';
2414
+ if (remoteSha && sha && remoteSha === sha) state = 'same';
2415
+ else if (remoteSha && sha) state = 'differs';
2416
+ else if (remoteSha) state = 'remote-only';
2417
+ return {
2418
+ id: s.id, title: s.title || '', cwd: s.cwd, slug: s.slug,
2419
+ workspaceId: s.workspaceId || owner.get(s.id) || null,
2420
+ size: s.size, mtime: s.mtime, relLog: s.relLog, state,
2421
+ };
2422
+ });
2423
+ out.localSessionCount = local.length;
2424
+ out.remoteSessionCount = [...remoteFiles.keys()].filter((p) => isSessionLogPath(p)).length;
2425
+ out.workspaces = this.workspaceRecords().map((w) => {
2426
+ const resolved = this.resolveWorkspacePath(w, cfg);
2427
+ return {
2428
+ id: w.id, title: w.title,
2429
+ path: resolved.path,
2430
+ recordedPath: w.path,
2431
+ pathSource: resolved.source,
2432
+ exists: Boolean(resolved.path) && fs.existsSync(resolved.path),
2433
+ sessionCount: w.sessionIds.length,
2434
+ };
2435
+ });
2436
+ } catch (e) {
2437
+ out.scanError = String(e?.message || e);
2438
+ }
2439
+ return out;
2440
+ }
2441
+ }
2442
+
2443
+ /* ================= 小工具 ================= */
2444
+
2445
+ function readJson(file) {
2446
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
2447
+ }
2448
+
2449
+ /**
2450
+ * 把记录里的绝对路径换成本机主目录下的同名相对路径。
2451
+ * /home/alice/work → /home/bob/work;C:\\Users\\alice\\work → 本机主目录\\work。
2452
+ * 认不出来就返回 null(调用方回退到原路径)。
2453
+ */
2454
+ function remapHomePrefix(recorded) {
2455
+ const home = os.homedir();
2456
+ const posix = String(recorded).replace(/\\/g, '/');
2457
+ let m = /^(\/(?:home|Users)\/[^/]+)(\/.*)?$/.exec(posix);
2458
+ if (m) return home + (m[2] || '');
2459
+ if (/^\/root(\/.*)?$/.test(posix)) return home + (posix.slice(5) || '');
2460
+ m = /^[A-Za-z]:[\\/]Users[\\/][^\\/]+([\\/].*)?$/i.exec(String(recorded));
2461
+ if (m) return home + (m[1] || '');
2462
+ return null;
2463
+ }
2464
+
2465
+ /** 把 git 的 fetch/push 报错翻成一句能指导操作的话 */
2466
+ function authOrFetchError(r) {
2467
+ const err = String(r?.err || '');
2468
+ if (/could not read Username|terminal prompts disabled|Authentication failed|not logged in|failed to execute prompt/i.test(err)) {
2469
+ return 'GitHub 认证失败,请执行 gh auth login 并让 git 使用 gh 凭据助手。';
2470
+ }
2471
+ if (/Could not resolve host|unable to access|Connection (timed out|refused)|Failed to connect/i.test(err)) {
2472
+ return `无法连接远端:${clip(err)};如 GitHub 访问不稳定,可在「连接设置」中配置 Git 代理。`;
2473
+ }
2474
+ return `读取远端失败:${clip(err || r?.code)}`;
2475
+ }
2476
+
2477
+ /**
2478
+ * 规格表 A 的判定:只看三个版本「存不存在」与「是否相等」。
2479
+ * b/l/r 为 { sha, mode } 或 null。
2480
+ */
2481
+ export function classifyState(b, l, r) {
2482
+ const same = (x, y) => Boolean(x && y && x.sha === y.sha) || (!x && !y);
2483
+ if (same(l, r)) return 'A1'; // 已同步
2484
+ if (!b && l && !r) return 'A6'; // 本端新增
2485
+ if (!b && !l && r) return 'A7'; // 远端新增
2486
+ if (!b && l && r) return 'A8'; // 双方独立新增同一 id
2487
+ if (b && !l && r) return same(r, b) ? 'A9' : 'A12'; // 本端删除(远端未改 / 远端也改了)
2488
+ if (b && l && !r) return same(l, b) ? 'A11' : 'A13';// 远端删除(本端未改 / 本端也改了)
2489
+ if (b && !l && !r) return 'A10'; // 双方都删除
2490
+ if (same(l, b)) return 'A3'; // 仅远端改
2491
+ if (same(r, b)) return 'A2'; // 仅本端改
2492
+ return 'A4'; // 两边都改 —— 是否可拼接交给内容层判断
2493
+ }
2494
+
2495
+ /** 单元类型:会话日志 / 派生缓存 / 文本 / 二进制 */
2496
+ export function unitKindOf(rel) {
2497
+ if (isSessionLogPath(rel)) return 'session-log';
2498
+ if (isRegenerableCache(rel)) return 'cache';
2499
+ const p = normRel(rel);
2500
+ if (/(^|\/)(node_modules|workspace-repos)\//.test(p)) return 'binary';
2501
+ return 'text';
2502
+ }
2503
+
2504
+ function looksBinary(buf) {
2505
+ if (!Buffer.isBuffer(buf)) return false;
2506
+ const n = Math.min(buf.length, 8192);
2507
+ for (let i = 0; i < n; i += 1) if (buf[i] === 0) return true;
2508
+ return false;
2509
+ }
2510
+
2511
+ function holdReasonText(reason) {
2512
+ switch (reason) {
2513
+ case 'divergent-session-history': return '会话双方均有新增内容,序列号冲突,无法安全拼接';
2514
+ case 'header-mismatch': return '会话表头不一致';
2515
+ case 'unreadable': return '会话日志无法解析';
2516
+ case 'text-overlap': return '文件双方修改同一位置';
2517
+ case 'binary-both-changed': return '二进制文件双方均已修改';
2518
+ case 'no-common-base': return '无共同历史,无法判定改动来源';
2519
+ case 'remote-deleted': return '远端已删除、本机未修改;删除不会自动生效';
2520
+ case 'delete-local': return '本机已删除、远端未修改;删除不会自动传播';
2521
+ case 'delete-modify': return '本机已删除、远端已修改';
2522
+ case 'modify-delete': return '远端已删除、本机已修改';
2523
+ default: return reason;
2524
+ }
2525
+ }
2526
+
2527
+ /** 表 A 的状态 → 给人看的说法 */
2528
+ export const STATE_LABEL = {
2529
+ A1: '已同步',
2530
+ A2: '仅本机已修改',
2531
+ A3: '仅远端已修改',
2532
+ A4: '双方已修改·可自动合并',
2533
+ A5: '双方已修改·待裁决',
2534
+ A6: '本机新增',
2535
+ A7: '远端新增',
2536
+ A8: '双方各自新增',
2537
+ A9: '本机已删除',
2538
+ A10: '双方均已删除',
2539
+ A11: '远端已删除',
2540
+ A12: '本机已删除·远端已修改',
2541
+ A13: '远端已删除·本机已修改',
2542
+ IGNORED: '已忽略',
2543
+ };
2544
+
2545
+ export function isRegenerableCache(rel) {
2546
+ const p = normRel(rel);
2547
+ return p.startsWith('storages/session_projcache/');
2548
+ }
2549
+
2550
+ export function isSessionLogPath(rel) {
2551
+ return SESSION_LOG_RE.test(String(rel || '').split('/').pop() || '') && /^sessions\//.test(normRel(rel));
2552
+ }
2553
+
2554
+ /** storages/session_projcache/sessions/<id>.json → <id>(会话投影缓存跟着会话走) */
2555
+ export function sessionCacheIdOf(rel) {
2556
+ const m = /^storages\/session_projcache\/sessions\/(.+)\.json$/i.exec(normRel(rel));
2557
+ return m ? m[1] : null;
2558
+ }
2559
+
2560
+ export function sessionIdOfPath(rel) {
2561
+ const m = /^sessions\/[^/]+\/([^/]+)\//.exec(normRel(rel));
2562
+ return m ? m[1] : null;
2563
+ }
2564
+
2565
+ export function shortId(id) {
2566
+ const s = String(id || '');
2567
+ const m = /([0-9a-f]{8})-[0-9a-f]{4}/i.exec(s);
2568
+ return m ? m[1] : s.slice(0, 12);
2569
+ }
2570
+
2571
+ function describeScope(opts, scope) {
2572
+ if (opts.scope === 'all') return '全部会话';
2573
+ const n = scope?.sessionIds?.length ?? (opts.sessionIds || []).length;
2574
+ return `${n} 个会话`;
2575
+ }
2576
+
2577
+ function logTransfer(log, prefix, chunk) {
2578
+ const text = String(chunk || '');
2579
+ for (const line of text.split(/[\r\n]+/)) {
2580
+ const t = line.trim();
2581
+ if (!t) continue;
2582
+ if (!/^(remote:|Receiving objects|Resolving deltas|Writing objects|Compressing objects|Enumerating objects|Counting objects|Total |POST |Pushing|From |To )/i.test(t)) continue;
2583
+ // 进度条每 1% 就写一行会把过程日志淹掉;只留完成态与汇总行。
2584
+ const pct = /(\d+)%/.exec(t);
2585
+ if (pct && Number(pct[1]) < 100) continue;
2586
+ if (/\bdone\.?$/i.test(t) && pct && Number(pct[1]) < 100) continue;
2587
+ if (log.last()?.text === `${prefix}: ${t}`) continue;
2588
+ log.info(prefix, t);
2589
+ }
2590
+ }
2591
+
2592
+ export function summarize(r) {
2593
+ if (!r) return '无结果';
2594
+ if (r.error) return `未完成:${r.error}`;
2595
+ const parts = [];
2596
+ if (r.direction === 'push') {
2597
+ if (r.commits?.length) parts.push(`提交 ${r.commits.length} 项快照`);
2598
+ if (r.pushed?.length) parts.push('已推送至远端');
2599
+ else if (!r.commits?.length) parts.push('无需上传的改动');
2600
+ const ws = (r.workspaces || []).filter((w) => w.pushed);
2601
+ if (ws.length) parts.push(`已推送 ${ws.length} 个工作区`);
2602
+ } else {
2603
+ if (r.applied?.length) parts.push(`写入 ${r.applied.length} 个文件`);
2604
+ if (r.merged?.length) parts.push(`自动合并 ${r.merged.length} 个文件`);
2605
+ }
2606
+ if (r.conflicts?.length) parts.push(`${r.conflicts.length} 项冲突待裁决`);
2607
+ const wserr = (r.workspaces || []).filter((w) => w.error);
2608
+ if (wserr.length) parts.push(`${wserr.length} 个工作区失败`);
2609
+ if (!parts.length) parts.push('已是最新');
2610
+ return parts.join(';');
2611
+ }
2612
+
2613
+ export { humanSize };