@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/LICENSE +21 -0
- package/README.md +234 -2
- package/client.js +1017 -0
- package/cordis.patch.yml +5 -0
- package/docs/design-sync-model.md +182 -0
- package/icon.svg +7 -0
- package/index.js +628 -0
- package/lib/engine.js +2613 -0
- package/lib/progress.js +96 -0
- package/lib/session.js +239 -0
- package/lib/util.js +214 -0
- package/locale/en.json +4 -0
- package/locale/zh.json +4 -0
- package/package.json +56 -4
- package/tests/client-render.test.mjs +588 -0
- package/tests/delivery.test.mjs +399 -0
- package/tests/engine.test.mjs +373 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 新交付模型的专项测试(规格 docs/design-sync-model.md)。
|
|
3
|
+
*
|
|
4
|
+
* 重点验证的是「推送不再强迫本端拉取」这条核心性质,以及忽略、部分交付:
|
|
5
|
+
* 1. 本端落后 main 时仍能推送自己的改动
|
|
6
|
+
* 2. 推送后 main 同时拥有双方内容,而本端工作树一个字节都没变
|
|
7
|
+
* 3. 本端永不分叉(ahead 恒为 0),想拉时是纯快进
|
|
8
|
+
* 4. 被忽略的会话既不推也不拉,而且不阻塞其他会话交付
|
|
9
|
+
* 5. 一次推送里既有可交付单元又有真冲突时:能交付的先交付
|
|
10
|
+
*
|
|
11
|
+
* 运行:node tests/delivery.test.mjs
|
|
12
|
+
* 只用自己创建的临时目录,不碰真实 ~/.dsh,也不改 ~/.gitconfig。
|
|
13
|
+
*/
|
|
14
|
+
import assert from 'node:assert/strict';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { randomUUID } from 'node:crypto';
|
|
19
|
+
import { SyncEngine } from '../lib/engine.js';
|
|
20
|
+
import { sessionSlugFromCwd } from '../lib/session.js';
|
|
21
|
+
import { zstdEncode, zstdDecode, run } from '../lib/util.js';
|
|
22
|
+
|
|
23
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dcs-deliver-'));
|
|
24
|
+
const remoteDir = path.join(root, 'remote.git');
|
|
25
|
+
const results = [];
|
|
26
|
+
|
|
27
|
+
function step(name, fn) {
|
|
28
|
+
return Promise.resolve().then(fn)
|
|
29
|
+
.then(() => { results.push(['ok', name]); process.stdout.write(` ✓ ${name}\n`); })
|
|
30
|
+
.catch((e) => { results.push(['fail', name, e]); process.stdout.write(` ✗ ${name}\n ${e?.message || e}\n`); });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const CWD = '/home/alice/project';
|
|
34
|
+
const SX = 'session-xxxx1111-0000-4000-8000-000000000001';
|
|
35
|
+
const SY = 'session-yyyy2222-0000-4000-8000-000000000002';
|
|
36
|
+
const SZ = 'session-zzzz3333-0000-4000-8000-000000000003';
|
|
37
|
+
const SW = 'session-wwww4444-0000-4000-8000-000000000004';
|
|
38
|
+
|
|
39
|
+
function userMsg(text, seq) {
|
|
40
|
+
return { type: 'user/message', seq, time: 1791000000000 + seq, data: { content: [{ type: 'text', text }], role: 'user', id: randomUUID() }, surfaceOp: 'append' };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sessionBuffer(id, events) {
|
|
44
|
+
const header = { type: 'session', version: 4, id, createdAt: 1791000000000, cwd: CWD, isSeeded: false, delegationDepth: 0, agentPreset: 'standard' };
|
|
45
|
+
return zstdEncode([header, ...events].map((r) => JSON.stringify(r)).join('\n') + '\n');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function makeHome(name, sessions = []) {
|
|
49
|
+
const home = path.join(root, name);
|
|
50
|
+
fs.mkdirSync(path.join(home, 'sessions'), { recursive: true });
|
|
51
|
+
fs.mkdirSync(path.join(home, 'storages', 'session_projcache', 'sessions'), { recursive: true });
|
|
52
|
+
for (const s of sessions) {
|
|
53
|
+
const dir = path.join(home, 'sessions', sessionSlugFromCwd(CWD), s.id);
|
|
54
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
55
|
+
fs.writeFileSync(path.join(dir, 'session.v4.jsonl.zstd'), sessionBuffer(s.id, s.events || []));
|
|
56
|
+
fs.writeFileSync(path.join(home, 'storages', 'session_projcache', 'sessions', s.id + '.json'),
|
|
57
|
+
JSON.stringify({ version: 7, record: { identity: { cwd: CWD }, rows: { title: { ver: 1, seq: 1, val: s.title || s.id } } } }));
|
|
58
|
+
}
|
|
59
|
+
fs.writeFileSync(path.join(home, 'storages', 'workspace.json'), JSON.stringify({
|
|
60
|
+
unit: { name: 'workspace', version: 2 },
|
|
61
|
+
global: { initialized: true, workspaceIds: [], archivedSessionIds: [], pinnedSessionIds: [] },
|
|
62
|
+
tables: { workspaces: {} },
|
|
63
|
+
}, null, 2));
|
|
64
|
+
return home;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function sessionFile(home, id) {
|
|
68
|
+
const p = path.join(home, 'sessions', sessionSlugFromCwd(CWD), id, 'session.v4.jsonl.zstd');
|
|
69
|
+
return fs.existsSync(p) ? p : null;
|
|
70
|
+
}
|
|
71
|
+
function sessionTexts(home, id) {
|
|
72
|
+
const p = sessionFile(home, id);
|
|
73
|
+
if (!p) return null;
|
|
74
|
+
return zstdDecode(fs.readFileSync(p)).toString('utf8').split('\n').filter(Boolean)
|
|
75
|
+
.map((l) => JSON.parse(l)).map((r) => r.data?.content?.[0]?.text).filter(Boolean);
|
|
76
|
+
}
|
|
77
|
+
function appendEvents(home, id, texts) {
|
|
78
|
+
const p = sessionFile(home, id);
|
|
79
|
+
assert.ok(p, `找不到会话文件 ${id}`);
|
|
80
|
+
const rows = zstdDecode(fs.readFileSync(p)).toString('utf8').split('\n').filter(Boolean);
|
|
81
|
+
const next = rows.length - 1;
|
|
82
|
+
const more = texts.map((t, i) => JSON.stringify(userMsg(t, next + i)));
|
|
83
|
+
fs.writeFileSync(p, zstdEncode(rows.concat(more).join('\n') + '\n'));
|
|
84
|
+
}
|
|
85
|
+
async function counts(engine) {
|
|
86
|
+
const s = await engine.status();
|
|
87
|
+
return { ahead: s.repo.ahead, behind: s.repo.behind };
|
|
88
|
+
}
|
|
89
|
+
function mainSessionTexts(home, id) {
|
|
90
|
+
// 直接读远端 main 上那个会话的内容(用本机仓库的 origin/main 引用)
|
|
91
|
+
const rel = `sessions/${sessionSlugFromCwd(CWD)}/${id}/session.v4.jsonl.zstd`;
|
|
92
|
+
return run('git', ['-C', home, 'show', `origin/main:${rel}`], { binary: true }).then((r) => {
|
|
93
|
+
if (r.code !== 0) return null;
|
|
94
|
+
return zstdDecode(r.out).toString('utf8').split('\n').filter(Boolean)
|
|
95
|
+
.map((l) => JSON.parse(l)).map((x) => x.data?.content?.[0]?.text).filter(Boolean);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function main() {
|
|
100
|
+
process.stdout.write(`临时目录:${root}\n`);
|
|
101
|
+
fs.mkdirSync(remoteDir, { recursive: true });
|
|
102
|
+
assert.equal((await run('git', ['init', '--bare', '-b', 'main', remoteDir])).code, 0);
|
|
103
|
+
const fakeGlobal = path.join(root, 'gitconfig');
|
|
104
|
+
fs.writeFileSync(fakeGlobal, '[protocol "file"]\n\tallow = always\n');
|
|
105
|
+
process.env.GIT_CONFIG_GLOBAL = fakeGlobal;
|
|
106
|
+
|
|
107
|
+
const cfg = { remote: 'file://' + remoteDir, branch: 'main', gitUserName: 't', gitUserEmail: 't@t', autoRepo: false };
|
|
108
|
+
const homeA = makeHome('home-a', [
|
|
109
|
+
{ id: SX, title: 'X', events: [userMsg('X 起始', 0)] },
|
|
110
|
+
{ id: SY, title: 'Y', events: [userMsg('Y 起始', 0)] },
|
|
111
|
+
{ id: SZ, title: 'Z', events: [userMsg('Z 起始', 0)] },
|
|
112
|
+
{ id: SW, title: 'W', events: [userMsg('W 起始', 0)] },
|
|
113
|
+
]);
|
|
114
|
+
const A = new SyncEngine(homeA, cfg, () => {});
|
|
115
|
+
const homeB = makeHome('home-b');
|
|
116
|
+
const B = new SyncEngine(homeB, cfg, () => {});
|
|
117
|
+
|
|
118
|
+
await step('准备:A 首次上传,B 取回', async () => {
|
|
119
|
+
assert.equal((await A.execute({ direction: 'push', scope: 'all' })).ok, true);
|
|
120
|
+
assert.equal((await B.execute({ direction: 'pull', scope: 'all' })).ok, true);
|
|
121
|
+
assert.ok(sessionTexts(homeB, SX), 'B 应该拿到了 X');
|
|
122
|
+
assert.ok(sessionTexts(homeB, SW), 'B 应该拿到了 W');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
let aXBefore = null;
|
|
126
|
+
await step('B 改会话 X 并推送;A 改会话 Y —— 此时 A 落后 main', async () => {
|
|
127
|
+
appendEvents(homeB, SX, ['B 改了 X']);
|
|
128
|
+
assert.equal((await B.execute({ direction: 'push', scope: 'all' })).ok, true);
|
|
129
|
+
appendEvents(homeA, SY, ['A 改了 Y']);
|
|
130
|
+
await A.git(['fetch', 'origin', '--prune']);
|
|
131
|
+
const c = await counts(A);
|
|
132
|
+
assert.ok(c.behind > 0, `A 应当落后 main,实际 behind=${c.behind}`);
|
|
133
|
+
aXBefore = fs.readFileSync(sessionFile(homeA, SX));
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
await step('核心性质:A 落后 main 时推送成功,且 main 同时拿到双方内容', async () => {
|
|
137
|
+
const r = await A.execute({ direction: 'push', scope: 'all' });
|
|
138
|
+
assert.equal(r.ok, true, JSON.stringify(r.result?.error || r.error));
|
|
139
|
+
assert.equal(r.result.conflicts.length, 0, '不该有冲突');
|
|
140
|
+
await A.git(['fetch', 'origin', '--prune']);
|
|
141
|
+
const mx = await mainSessionTexts(homeA, SX);
|
|
142
|
+
const my = await mainSessionTexts(homeA, SY);
|
|
143
|
+
assert.ok(mx.includes('B 改了 X'), 'main 上 X 应当是 B 的版本');
|
|
144
|
+
assert.ok(my.includes('A 改了 Y'), 'main 上 Y 应当是 A 的版本');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await step('核心性质:A 的本机工作树一个字节都没变,也没被迫拉取', async () => {
|
|
148
|
+
assert.ok(aXBefore.equals(fs.readFileSync(sessionFile(homeA, SX))), 'A 本机的 X 必须保持原样');
|
|
149
|
+
assert.ok(!sessionTexts(homeA, SX).includes('B 改了 X'), 'A 本机不该看到 B 对 X 的改动');
|
|
150
|
+
assert.ok(sessionTexts(homeA, SY).includes('A 改了 Y'), 'A 本机的 Y 是它自己改的');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
await step('核心性质:A 永不分叉(ahead=0),想拉时是纯快进', async () => {
|
|
154
|
+
const c = await counts(A);
|
|
155
|
+
assert.equal(c.ahead, 0, `A 不该领先 main(ahead=${c.ahead})`);
|
|
156
|
+
assert.ok(c.behind > 0, 'A 应当只是落后');
|
|
157
|
+
// 纯快进:HEAD 是 origin/main 的祖先,merge --ff-only 必然成功
|
|
158
|
+
const ff = await A.git(['merge', '--ff-only', 'origin/main']);
|
|
159
|
+
assert.equal(ff.code, 0, `快进应当成功:${ff.err}`);
|
|
160
|
+
assert.ok(sessionTexts(homeA, SX).includes('B 改了 X'), '快进后才拿到 B 的 X');
|
|
161
|
+
const c2 = await counts(A);
|
|
162
|
+
assert.equal(c2.behind, 0);
|
|
163
|
+
assert.equal(c2.ahead, 0);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
/* ---------------- 忽略 ---------------- */
|
|
167
|
+
await step('忽略:本机忽略会话 X 后,main 上 X 的新版本不会被本机拉下来', async () => {
|
|
168
|
+
B.writeIgnoreConfig({}); // 清一下,确保 B 不忽略
|
|
169
|
+
A.writeIgnoreConfig({ sessions: [SY] }); // 先只忽略 Y,专心测 X 的拉取
|
|
170
|
+
appendEvents(homeB, SX, ['B 又改了 X']);
|
|
171
|
+
assert.equal((await B.execute({ direction: 'push', scope: 'all' })).ok, true);
|
|
172
|
+
A.writeIgnoreConfig({ sessions: [SX] }); // 现在忽略 X
|
|
173
|
+
const before = fs.readFileSync(sessionFile(homeA, SX));
|
|
174
|
+
const r = await A.execute({ direction: 'pull', scope: 'all' });
|
|
175
|
+
assert.equal(r.ok, true, JSON.stringify(r.result?.error || r.error));
|
|
176
|
+
assert.ok(before.equals(fs.readFileSync(sessionFile(homeA, SX))), '被忽略的 X 不该被拉取更新');
|
|
177
|
+
A.writeIgnoreConfig({ sessions: [] });
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
await step('忽略:被忽略的会话即使落后,也不阻塞其他会话交付给 main', async () => {
|
|
181
|
+
// A 忽略 X(此时 X 在 main 上更新、在本机落后),改 Y 并推送
|
|
182
|
+
A.writeIgnoreConfig({ sessions: [SX] });
|
|
183
|
+
appendEvents(homeA, SY, ['A 再改 Y']);
|
|
184
|
+
const r = await A.execute({ direction: 'push', scope: 'all' });
|
|
185
|
+
assert.equal(r.ok, true, JSON.stringify(r.result?.error || r.error));
|
|
186
|
+
await A.git(['fetch', 'origin', '--prune']);
|
|
187
|
+
const mx = await mainSessionTexts(homeA, SX);
|
|
188
|
+
const my = await mainSessionTexts(homeA, SY);
|
|
189
|
+
assert.ok(mx.includes('B 又改了 X'), 'main 上 X 必须仍是 B 的版本(忽略不得回退它)');
|
|
190
|
+
assert.ok(my.includes('A 再改 Y'), 'main 应当拿到 A 对 Y 的新改动');
|
|
191
|
+
A.writeIgnoreConfig({ sessions: [] });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
/* ---------------- 部分交付 ---------------- */
|
|
195
|
+
await step('部分交付:能交付的先交付,真冲突的留下等裁决', async () => {
|
|
196
|
+
// 把 X 重新对齐,避免上一轮的"落后"干扰判断
|
|
197
|
+
await A.execute({ direction: 'pull', scope: 'all' });
|
|
198
|
+
await B.execute({ direction: 'pull', scope: 'all' });
|
|
199
|
+
|
|
200
|
+
// 先让两边完全同步,然后 B 改 X 推上去,A 在没拉取的情况下也改 X → 真正的分叉
|
|
201
|
+
assert.equal((await A.execute({ direction: 'pull', scope: 'all' })).ok, true);
|
|
202
|
+
assert.equal((await B.execute({ direction: 'pull', scope: 'all' })).ok, true);
|
|
203
|
+
appendEvents(homeB, SX, ['B 分叉 X']);
|
|
204
|
+
assert.equal((await B.execute({ direction: 'push', scope: 'all' })).ok, true);
|
|
205
|
+
appendEvents(homeA, SX, ['A 分叉 X']); // A 没拉过 B 的改动 → 与 main 分叉
|
|
206
|
+
appendEvents(homeA, SZ, ['A 改 Z']); // Z 是干净的可交付单元
|
|
207
|
+
|
|
208
|
+
const r = await A.execute({ direction: 'push', scope: 'all' });
|
|
209
|
+
assert.equal(r.ok, false, '有真冲突时应报告未完成');
|
|
210
|
+
assert.equal(r.result.conflicts.length, 1, '恰好一项待裁决');
|
|
211
|
+
assert.equal(r.result.conflicts[0].sessionId, SX);
|
|
212
|
+
assert.ok(r.result.delivered?.length, 'Z 应当已经被交付');
|
|
213
|
+
await A.git(['fetch', 'origin', '--prune']);
|
|
214
|
+
const mx = await mainSessionTexts(homeA, SX);
|
|
215
|
+
const mz = await mainSessionTexts(homeA, SZ);
|
|
216
|
+
assert.ok(!mx.includes('A 分叉 X'), 'main 的 X 不该被 A 的分叉版覆盖');
|
|
217
|
+
assert.ok(mz.includes('A 改 Z'), 'main 应当已经拿到 A 的 Z');
|
|
218
|
+
assert.ok(sessionTexts(homeA, SX).includes('A 分叉 X'), 'A 本机的 X 保持原样');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
await step('待裁决的会话:本机文件不动,裁决「两边都留」后收敛', async () => {
|
|
222
|
+
const pending = A.listPending();
|
|
223
|
+
assert.equal(pending.length, 1);
|
|
224
|
+
const c = pending[0].conflicts[0];
|
|
225
|
+
const before = fs.readFileSync(sessionFile(homeA, SX));
|
|
226
|
+
assert.ok(before.length > 0);
|
|
227
|
+
const r = await A.resolveConflicts({ pendingId: pending[0].id, decisions: [{ id: c.id, choice: 'both' }] });
|
|
228
|
+
assert.equal(r.ok, true, JSON.stringify(r.error));
|
|
229
|
+
assert.ok(r.detail.both[0].newSessionId, '应当导入了远端那一支为新会话');
|
|
230
|
+
assert.equal(A.listPending().length, 0, '待裁决记录已清空');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
await step('强制覆盖:用本机版本覆盖 main', async () => {
|
|
234
|
+
const rel = `sessions/${sessionSlugFromCwd(CWD)}/${SW}/session.v4.jsonl.zstd`;
|
|
235
|
+
appendEvents(homeB, SW, ['B 在 W 上写的']);
|
|
236
|
+
assert.equal((await B.execute({ direction: 'push', scope: 'all' })).ok, true);
|
|
237
|
+
appendEvents(homeA, SW, ['A 在 W 上写的']); // A 没拉过 → 两边分叉
|
|
238
|
+
|
|
239
|
+
const p0 = await A.execute({ direction: 'push', scope: 'all' });
|
|
240
|
+
assert.equal(p0.ok, false, '不强制时应当报冲突');
|
|
241
|
+
|
|
242
|
+
const r = await A.forceUnit(rel, 'push');
|
|
243
|
+
assert.equal(r.ok, true, JSON.stringify(r.error));
|
|
244
|
+
await A.git(['fetch', 'origin', '--prune']);
|
|
245
|
+
const mw = await mainSessionTexts(homeA, SW);
|
|
246
|
+
assert.ok(mw.includes('A 在 W 上写的'), 'main 应当被本机版本覆盖');
|
|
247
|
+
assert.ok(!mw.includes('B 在 W 上写的'), 'main 上不该再有远端那一版');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
await step('强制覆盖:用远端版本覆盖本机', async () => {
|
|
251
|
+
const rel = `sessions/${sessionSlugFromCwd(CWD)}/${SW}/session.v4.jsonl.zstd`;
|
|
252
|
+
// B 在自己的版本上继续写并强制推上去 → main 与 A 又不一致
|
|
253
|
+
appendEvents(homeB, SW, ['B 又写了一句']);
|
|
254
|
+
const rb = await B.forceUnit(rel, 'push');
|
|
255
|
+
assert.equal(rb.ok, true, JSON.stringify(rb.error));
|
|
256
|
+
await A.git(['fetch', 'origin', '--prune']);
|
|
257
|
+
|
|
258
|
+
const r = await A.forceUnit(rel, 'pull');
|
|
259
|
+
assert.equal(r.ok, true, JSON.stringify(r.error));
|
|
260
|
+
const aw = sessionTexts(homeA, SW);
|
|
261
|
+
assert.ok(aw.includes('B 在 W 上写的') && aw.includes('B 又写了一句'), '本机应当被 main 的版本覆盖');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
await step('强制覆盖会消解同一条待裁决记录', async () => {
|
|
265
|
+
const rel = `sessions/${sessionSlugFromCwd(CWD)}/${SW}/session.v4.jsonl.zstd`;
|
|
266
|
+
appendEvents(homeB, SW, ['B 制造分叉']);
|
|
267
|
+
const rb = await B.execute({ direction: 'push', scope: 'all' });
|
|
268
|
+
if (!rb.ok) await B.forceUnit(rel, 'push'); // 若 B 自己也分叉了就直接强制
|
|
269
|
+
appendEvents(homeA, SW, ['A 制造分叉']);
|
|
270
|
+
const hold = await A.execute({ direction: 'push', scope: 'all' });
|
|
271
|
+
assert.equal(hold.ok, false, '这一步应当产生待裁决');
|
|
272
|
+
assert.ok(A.listPending().some((x) => x.conflicts.some((c) => c.path === rel)), '应当登记了 SW 的冲突');
|
|
273
|
+
|
|
274
|
+
const r = await A.forceUnit(rel, 'push');
|
|
275
|
+
assert.equal(r.ok, true, JSON.stringify(r.error));
|
|
276
|
+
assert.equal(r.clearedPending, true, '强制覆盖应当顺带消解那条待裁决');
|
|
277
|
+
assert.ok(!A.listPending().some((x) => x.conflicts.some((c) => c.path === rel)), 'SW 不该再留在待裁决里');
|
|
278
|
+
await A.git(['fetch', 'origin', '--prune']);
|
|
279
|
+
const mw = await mainSessionTexts(homeA, SW);
|
|
280
|
+
assert.ok(mw.includes('A 制造分叉'), 'main 应当被本机版本覆盖');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
await step('推送侧裁决「保留本机」= 用本机覆盖 main,绝不能被当成删除', async () => {
|
|
284
|
+
const rel = `sessions/${sessionSlugFromCwd(CWD)}/${SW}/session.v4.jsonl.zstd`;
|
|
285
|
+
// 重新制造一个分叉
|
|
286
|
+
await A.execute({ direction: 'pull', scope: 'all' });
|
|
287
|
+
appendEvents(homeB, SW, ['B 最后一次']);
|
|
288
|
+
const rb = await B.execute({ direction: 'push', scope: 'all' });
|
|
289
|
+
if (!rb.ok) await B.forceUnit(rel, 'push');
|
|
290
|
+
appendEvents(homeA, SW, ['A 最后一次']);
|
|
291
|
+
const hold = await A.execute({ direction: 'push', scope: 'all' });
|
|
292
|
+
assert.equal(hold.ok, false, '应当产生待裁决');
|
|
293
|
+
|
|
294
|
+
const pending = A.listPending();
|
|
295
|
+
const c = pending.flatMap((x) => x.conflicts).find((x) => x.path === rel);
|
|
296
|
+
assert.ok(c, '应当登记了 SW 的冲突');
|
|
297
|
+
|
|
298
|
+
const r = await A.resolveConflicts({ pendingId: pending[0].id, decisions: [{ id: c.id, choice: 'local' }] });
|
|
299
|
+
assert.equal(r.ok, true, JSON.stringify(r.error));
|
|
300
|
+
|
|
301
|
+
await A.git(['fetch', 'origin', '--prune']);
|
|
302
|
+
const mw = await mainSessionTexts(homeA, SW);
|
|
303
|
+
assert.ok(mw, 'main 上这个会话必须还在 —— 选「保留本机」不是删除');
|
|
304
|
+
assert.ok(mw.length > 0, 'main 上这个会话不能变成空文件');
|
|
305
|
+
assert.ok(mw.includes('A 最后一次'), 'main 应当被本机版本覆盖');
|
|
306
|
+
assert.ok(sessionTexts(homeA, SW).includes('A 最后一次'), '本机这一版保持不变');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
await step('部分裁决:只提交已选的那一项,其余留在待裁决里', async () => {
|
|
310
|
+
// 同时制造两个分叉会话:X 与 W
|
|
311
|
+
const relW = `sessions/${sessionSlugFromCwd(CWD)}/${SW}/session.v4.jsonl.zstd`;
|
|
312
|
+
await A.execute({ direction: 'pull', scope: 'all' });
|
|
313
|
+
await B.execute({ direction: 'pull', scope: 'all' });
|
|
314
|
+
appendEvents(homeB, SX, ['B 分叉 X 二']);
|
|
315
|
+
appendEvents(homeB, SW, ['B 分叉 W 二']);
|
|
316
|
+
const rb = await B.execute({ direction: 'push', scope: 'all' });
|
|
317
|
+
if (!rb.ok) { for (const c of B.listPending().flatMap((x) => x.conflicts)) await B.forceUnit(c.path, 'push'); }
|
|
318
|
+
appendEvents(homeA, SX, ['A 分叉 X 二']);
|
|
319
|
+
appendEvents(homeA, SW, ['A 分叉 W 二']);
|
|
320
|
+
const hold = await A.execute({ direction: 'push', scope: 'all' });
|
|
321
|
+
assert.equal(hold.ok, false, '应当产生两项待裁决');
|
|
322
|
+
|
|
323
|
+
const store = A.listPending()[0];
|
|
324
|
+
assert.equal(store.conflicts.length, 2, `应当有两项待裁决,实际 ${store.conflicts.length}`);
|
|
325
|
+
const only = store.conflicts.find((c) => c.path === relW);
|
|
326
|
+
|
|
327
|
+
const r = await A.resolveConflicts({ pendingId: store.id, decisions: [{ id: only.id, choice: 'local' }] });
|
|
328
|
+
assert.equal(r.ok, true, JSON.stringify(r.error));
|
|
329
|
+
assert.equal(r.remaining, 1, '应当还剩一项待裁决');
|
|
330
|
+
|
|
331
|
+
const left = A.listPending();
|
|
332
|
+
assert.equal(left.length, 1, '待裁决记录应当保留');
|
|
333
|
+
assert.equal(left[0].conflicts.length, 1, '只剩没裁决的那一项');
|
|
334
|
+
assert.ok(left[0].conflicts[0].path !== relW, '已裁决的那项应当从待裁决里消失');
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
await step('删除类:裁决「只在本机删除」后不再重复问,且 main 保持原样', async () => {
|
|
338
|
+
const relW = `sessions/${sessionSlugFromCwd(CWD)}/${SW}/session.v4.jsonl.zstd`;
|
|
339
|
+
await A.execute({ direction: 'pull', scope: 'all' });
|
|
340
|
+
await B.execute({ direction: 'pull', scope: 'all' });
|
|
341
|
+
|
|
342
|
+
// 本机把 W 的会话整个删掉
|
|
343
|
+
fs.rmSync(path.dirname(sessionFile(homeA, SW)), { recursive: true, force: true });
|
|
344
|
+
fs.rmSync(path.join(homeA, 'storages', 'session_projcache', 'sessions', SW + '.json'), { force: true });
|
|
345
|
+
|
|
346
|
+
const r1 = await A.execute({ direction: 'push', scope: 'all' });
|
|
347
|
+
assert.equal(r1.ok, false, '删除应当进待裁决(删除绝不自动传播)');
|
|
348
|
+
const store = A.listPending()[0];
|
|
349
|
+
const c = store.conflicts.find((x) => x.path === relW);
|
|
350
|
+
assert.ok(c, '应当登记 W 的删除冲突');
|
|
351
|
+
assert.equal(c.reason, 'delete-local');
|
|
352
|
+
|
|
353
|
+
const r2 = await A.resolveConflicts({ pendingId: store.id, decisions: [{ id: c.id, choice: 'keep' }] });
|
|
354
|
+
assert.equal(r2.ok, true, JSON.stringify(r2.error));
|
|
355
|
+
await A.git(['fetch', 'origin', '--prune']);
|
|
356
|
+
const mw = await mainSessionTexts(homeA, SW);
|
|
357
|
+
assert.ok(mw && mw.length > 0, 'main 上这个会话必须还在');
|
|
358
|
+
|
|
359
|
+
// 再推一次:同一个删除不该被重复问(其他历史遗留的冲突与本用例无关,只断言 W 本身)
|
|
360
|
+
const r3 = await A.execute({ direction: 'push', scope: 'all' });
|
|
361
|
+
assert.equal(r3.ok, true, `不该重复报同一个删除:${r3.result?.error || ''}`);
|
|
362
|
+
assert.equal(
|
|
363
|
+
A.listPending().some((x) => x.conflicts.some((c) => c.path === relW)),
|
|
364
|
+
false,
|
|
365
|
+
'同一个删除不该被重复问第二次',
|
|
366
|
+
);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
await step('删除类:裁决「同步删除到 main」会把删除真的交付上去', async () => {
|
|
370
|
+
const relZ = `sessions/${sessionSlugFromCwd(CWD)}/${SZ}/session.v4.jsonl.zstd`;
|
|
371
|
+
fs.rmSync(path.dirname(sessionFile(homeA, SZ)), { recursive: true, force: true });
|
|
372
|
+
fs.rmSync(path.join(homeA, 'storages', 'session_projcache', 'sessions', SZ + '.json'), { force: true });
|
|
373
|
+
|
|
374
|
+
const r1 = await A.execute({ direction: 'push', scope: 'all' });
|
|
375
|
+
assert.equal(r1.ok, false, '应当进待裁决');
|
|
376
|
+
const store = A.listPending()[0];
|
|
377
|
+
const c = store.conflicts.find((x) => x.path === relZ);
|
|
378
|
+
assert.ok(c, '应当登记 Z 的删除冲突');
|
|
379
|
+
|
|
380
|
+
const r2 = await A.resolveConflicts({ pendingId: store.id, decisions: [{ id: c.id, choice: 'delete' }] });
|
|
381
|
+
assert.equal(r2.ok, true, JSON.stringify(r2.error));
|
|
382
|
+
await A.git(['fetch', 'origin', '--prune']);
|
|
383
|
+
const mz = await mainSessionTexts(homeA, SZ);
|
|
384
|
+
assert.equal(mz, null, 'main 上这个会话应当已经不存在');
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
const failed = results.filter((x) => x[0] === 'fail');
|
|
388
|
+
process.stdout.write(`\n${results.length - failed.length}/${results.length} 通过\n`);
|
|
389
|
+
if (failed.length) {
|
|
390
|
+
for (const [, name, err] of failed) process.stdout.write(`失败:${name}\n${err?.stack || err}\n`);
|
|
391
|
+
process.stdout.write(`(保留现场:${root})\n`);
|
|
392
|
+
process.exitCode = 1;
|
|
393
|
+
} else {
|
|
394
|
+
try { fs.rmSync(root, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
395
|
+
process.stdout.write(`已清理 ${root}\n`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
main().catch((e) => { console.error(e); process.exitCode = 1; });
|