dsh-config-manager 0.1.21 → 0.1.23

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.
@@ -454,3 +454,42 @@ test('集成:list 按 createdAt 升序(真实 git 多快照)', async (t) =
454
454
  const listed = await transport.list();
455
455
  assert.deepEqual(listed.map((m) => m.id), ['snap-a', 'snap-b', 'snap-c']);
456
456
  });
457
+
458
+ // ─── t5:远端快照裁剪的 git 契约(upload 先 push,再 delete 删旧,各自独立 commit+push) ───
459
+
460
+ test('集成:裁剪删除旧快照 → 每次 delete 独立 commit+push,add 提交先于 delete 提交', async (t) => {
461
+ const bare = await makeBareRepo(t);
462
+ const workDir = await makeTempDir(t);
463
+ const transport = new GitTransport({ repoUrl: bare, workDir, credentials: { getToken: async () => TEST_TOKEN } });
464
+
465
+ // 模拟裁剪场景:先上传多个快照(adds),随后逐个 delete 旧的
466
+ for (let i = 1; i <= 4; i++) {
467
+ await transport.upload(sampleSnapshot({ id: `snap-0${i}`, createdAt: `2026-08-16T0${i}:00:00.000Z` }));
468
+ }
469
+ // delete 3 个旧的,保留最新 1 个
470
+ await transport.delete('snap-01');
471
+ await transport.delete('snap-02');
472
+ await transport.delete('snap-03');
473
+
474
+ // list 只反映保留的快照
475
+ const listed = await transport.list();
476
+ assert.deepEqual(listed.map((m) => m.id), ['snap-04'], '裁剪后远端只保留最新快照');
477
+
478
+ // git 提交历史:先 add 后 delete(顺序正确,新快照先推再删旧)
479
+ const log = await runRealGit(['log', '--oneline', '--all'], bare);
480
+ assert.equal(log.code, 0, `git log 失败: ${log.stderr}`);
481
+ const addIdx = log.stdout.indexOf('sync: add snapshot snap-04');
482
+ const delIdx = log.stdout.indexOf('sync: delete snapshot snap-01');
483
+ assert.ok(addIdx >= 0, 'add 提交应存在');
484
+ assert.ok(delIdx >= 0, 'delete 提交应存在');
485
+ // git log 默认按最新提交在前;delete 是较新的提交,应出现在 add 之前(add 更旧在列表更靠后)
486
+ assert.ok(delIdx < addIdx, 'delete 提交应晚于(在 log 中靠前于)add 提交 → 先推新再删旧');
487
+
488
+ // 每个 delete 独立 commit+push
489
+ const delCount = (log.stdout.match(/sync: delete snapshot snap-0/g) ?? []).length;
490
+ assert.equal(delCount, 3, '每个被删快照对应一次独立 delete 提交');
491
+
492
+ // 已删除快照目录从工作副本移除
493
+ const dirs = await fs.readdir(path.join(workDir, 'snapshots'));
494
+ assert.deepEqual(dirs, ['snap-04']);
495
+ });
@@ -12,7 +12,7 @@ import fs from 'node:fs/promises';
12
12
  import os from 'node:os';
13
13
  import path from 'node:path';
14
14
 
15
- import { SyncEngine } from './sync-engine.ts';
15
+ import { SyncEngine, MAX_REMOTE_SNAPSHOTS } from './sync-engine.ts';
16
16
  import type { SyncApplyPlan } from './risk.ts';
17
17
  import { hashSection, loadSyncState, SYNC_STATE_FILE } from './sync-state.ts';
18
18
  import { computeSnapshotMeta } from './transport.ts';
@@ -47,6 +47,7 @@ class MemSyncTransport implements SyncTransport {
47
47
  async delete(id: string): Promise<void> {
48
48
  this.calls.push('delete');
49
49
  this.snapshots.delete(id);
50
+ this.metas = this.metas.filter((m) => m.id !== id);
50
51
  }
51
52
  }
52
53
 
@@ -686,3 +687,122 @@ test('applyItems: 空子计划 → 直接返回 ok:true(不调 Importer)', a
686
687
  await fs.rm(tmp, { recursive: true, force: true });
687
688
  }
688
689
  });
690
+
691
+ // ─── t5:push 后远端快照裁剪(保留最新 MAX_REMOTE_SNAPSHOTS 个) ─────────────────
692
+
693
+ /** 预置 n 个远端快照(id=remote-N,createdAt 递增) */
694
+ function seedRemoteSnapshots(transport: MemSyncTransport, n: number): void {
695
+ for (let i = 1; i <= n; i++) {
696
+ const id = `remote-${String(i).padStart(2, '0')}`;
697
+ const snap: SyncSnapshot = {
698
+ id,
699
+ createdAt: `2026-08-15T${String(i - 1).padStart(2, '0')}:00:00.000Z`,
700
+ manifest: { schemaVersion: 1, dshVersion: '1.2.3', platform: 'win32', sectionIds: ['settings'], containsSecrets: false },
701
+ sections: { settings: { version: 1, namespaces: {} } },
702
+ };
703
+ transport.snapshots.set(id, snap);
704
+ transport.metas.push(computeSnapshotMeta(snap));
705
+ }
706
+ }
707
+
708
+ test('push: 远端快照数超过 MAX_REMOTE_SNAPSHOTS → 裁剪只保留最新 10 个(含刚 push 的)', async () => {
709
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-prune-'));
710
+ try {
711
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
712
+ seedSource(ctx);
713
+ const transport = new MemSyncTransport();
714
+ seedRemoteSnapshots(transport, 13); // 预置 13 个旧快照
715
+ const engine = makeEngine({ ctx, transport, stateDir: tmp });
716
+
717
+ const report = await engine.push({ snapshotId: 'sync-new' });
718
+ assert.equal(report.ok, true);
719
+ // 本次 push 的快照应保留
720
+ assert.ok(transport.snapshots.has('sync-new'), '刚 push 的快照必须保留');
721
+
722
+ const remaining = [...transport.snapshots.keys()];
723
+ // 保留 13 个预置里最新的 9 个(remote-05..remote-13)+ 本次 push 的 sync-new = 10
724
+ assert.equal(remaining.length, MAX_REMOTE_SNAPSHOTS, `裁剪后应恰剩 ${MAX_REMOTE_SNAPSHOTS} 个`);
725
+ // 最旧的 4 个(remote-01..remote-04)被删
726
+ for (let i = 1; i <= 4; i++) {
727
+ assert.ok(!transport.snapshots.has(`remote-0${i}`), `最旧的 remote-0${i} 应被裁剪`);
728
+ }
729
+ // 最新保留集含 5..13 与 sync-new
730
+ for (let i = 5; i <= 13; i++) {
731
+ assert.ok(transport.snapshots.has(`remote-${String(i).padStart(2, '0')}`), `最新的 remote-${i} 应保留`);
732
+ }
733
+ // 裁剪通过 transport.delete 逐个删除(删除调用次数 = 4)
734
+ assert.equal(transport.calls.filter((c) => c === 'delete').length, 4);
735
+ // 顺序:upload → list(裁剪) → delete×4 → recordBaseline(无本地裁剪)→ push 返回
736
+ // 断言 upload 在首次 delete 之前(保证新快照先推送成功再删旧的)
737
+ assert.ok(transport.calls.indexOf('upload') < transport.calls.indexOf('delete'), '先 push 新快照再删旧的');
738
+ // 无裁剪告警(push 会带若干基础导出告警,如未注册 namespace,但不应有裁剪告警)
739
+ assert.ok(!report.warnings.some((w) => w.includes('裁剪')), '正常裁剪不应产生告警');
740
+ } finally {
741
+ await fs.rm(tmp, { recursive: true, force: true });
742
+ }
743
+ });
744
+
745
+ test('push: 远端快照数未超上限 → 不触发任何删除', async () => {
746
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-prune-ok-'));
747
+ try {
748
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
749
+ seedSource(ctx);
750
+ const transport = new MemSyncTransport();
751
+ seedRemoteSnapshots(transport, 9); // 9 旧 + 本次 1 = 10,恰好达标不裁剪
752
+ const engine = makeEngine({ ctx, transport, stateDir: tmp });
753
+
754
+ const report = await engine.push({ snapshotId: 'sync-new' });
755
+ assert.equal(report.ok, true);
756
+ assert.equal(transport.calls.filter((c) => c === 'delete').length, 0, '未超上限不得删除');
757
+ assert.equal(transport.snapshots.size, 10);
758
+ assert.ok(!report.warnings.some((w) => w.includes('裁剪')), '未超上限不应有裁剪告警');
759
+ } finally {
760
+ await fs.rm(tmp, { recursive: true, force: true });
761
+ }
762
+ });
763
+
764
+ test('push: 裁剪 list 失败 → 只告警不上抛,push 仍 ok', async () => {
765
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-prune-listfail-'));
766
+ try {
767
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
768
+ seedSource(ctx);
769
+ const transport = new MemSyncTransport();
770
+ seedRemoteSnapshots(transport, 13);
771
+ transport.list = async () => { throw new Error('list boom'); };
772
+ const engine = makeEngine({ ctx, transport, stateDir: tmp });
773
+
774
+ const report = await engine.push({ snapshotId: 'sync-new' });
775
+ assert.equal(report.ok, true, '裁剪失败不得阻断 push');
776
+ assert.ok(transport.snapshots.has('sync-new'), '快照本身已上传');
777
+ assert.ok(report.warnings.some((w) => w.includes('裁剪')), '应有裁剪告警');
778
+ assert.ok(report.warnings.some((w) => w.includes('无法列出远端快照')), '告警应说明 list 失败');
779
+ } finally {
780
+ await fs.rm(tmp, { recursive: true, force: true });
781
+ }
782
+ });
783
+
784
+ test('push: 裁剪单个 delete 失败 → 只告警不上抛,其余旧快照照常删除', async () => {
785
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-prune-delfail-'));
786
+ try {
787
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
788
+ seedSource(ctx);
789
+ const transport = new MemSyncTransport();
790
+ seedRemoteSnapshots(transport, 13);
791
+ // 让删除 remote-02 时抛错,其余正常
792
+ const origDelete = transport.delete.bind(transport);
793
+ transport.delete = async (id: string) => {
794
+ if (id === 'remote-02') throw new Error('delete boom');
795
+ return origDelete(id);
796
+ };
797
+ const engine = makeEngine({ ctx, transport, stateDir: tmp });
798
+
799
+ const report = await engine.push({ snapshotId: 'sync-new' });
800
+ assert.equal(report.ok, true, '单个删除失败不得阻断 push');
801
+ assert.ok(report.warnings.some((w) => w.includes('删除快照 remote-02 失败')), '应有删除失败告警');
802
+ // remote-02 仍残留(删除失败),其余旧快照被删
803
+ assert.ok(transport.snapshots.has('remote-02'), '删除失败的 remote-02 应残留');
804
+ assert.ok(!transport.snapshots.has('remote-01'), '其余旧快照照常删除');
805
+ } finally {
806
+ await fs.rm(tmp, { recursive: true, force: true });
807
+ }
808
+ });
@@ -164,6 +164,13 @@ export interface ApplyItemsReport {
164
164
  * 注:'credentials' 不是合法 SectionId——凭据状态分区为 credentialsStatus) */
165
165
  const FORBIDDEN_SECTIONS: readonly SectionId[] = ['credentialsStatus', 'secrets'];
166
166
 
167
+ /**
168
+ * 远端快照保留数量上限:每次 push 上传成功后对远端裁剪,
169
+ * 保留最新 N 个(按 createdAt 升序的最末 N 个,含刚 push 的),更旧的逐个删除。
170
+ * 只按数量裁剪,不按时间窗口。删除失败只告警(进 push 的 warnings),不上抛阻断主流程。
171
+ */
172
+ export const MAX_REMOTE_SNAPSHOTS = 10;
173
+
167
174
  export class SyncEngine {
168
175
  private readonly ctx: HostContext;
169
176
  private readonly transport: SyncTransport;
@@ -269,12 +276,48 @@ export class SyncEngine {
269
276
  }
270
277
  // ② 上传远端(传输通道负责散文件落盘 + 提交推送)
271
278
  await this.transport.upload(snapshot);
279
+ // ②·1 裁剪远端快照:保留最新 MAX_REMOTE_SNAPSHOTS 个(含刚 push 的)。
280
+ // 删除失败只告警(进 warnings),不上抛 —— 不阻断 push 主流程。
281
+ // 放在 upload 之后(保证新快照已推送成功)与 recordBaseline 之前(先管好远端再记基线)。
282
+ await this.pruneRemoteSnapshots(id, warnings);
272
283
  // ③ 记录祖先基线:写 sync-state(lastSnapshotId + 每分区 hash/updatedAt + lastSyncAt + transport)+ 裁剪
273
284
  await this.recordBaseline(id, snapshot.sections, nowIso);
274
285
 
275
286
  return { ok: true, snapshotId: id, sections: Object.keys(sections) as SectionId[], warnings };
276
287
  }
277
288
 
289
+ /**
290
+ * 裁剪远端快照:调用 transport.list() 获取全部快照(按 createdAt 升序),
291
+ * 保留最新 MAX_REMOTE_SNAPSHOTS 个(含刚 push 的 pushedId),对更旧的逐个调用 transport.delete()。
292
+ * 只按数量裁剪,不按时间窗口。
293
+ *
294
+ * 失败语义:list/delete 失败均只 push 进 warnings,不上抛 —— 裁剪是「尽力而为」的后台整理,
295
+ * 绝不影响 push 主流程的成功与否(§34.17 分区级告警同款语义)。
296
+ */
297
+ private async pruneRemoteSnapshots(pushedId: string, warnings: string[]): Promise<void> {
298
+ let metas: SyncSnapshotMeta[];
299
+ try {
300
+ metas = await this.transport.list();
301
+ } catch (err) {
302
+ const reason = err instanceof Error ? err.message : String(err);
303
+ warnings.push(this.msg('sync.pruneListFailed', { reason }));
304
+ return;
305
+ }
306
+ if (metas.length <= MAX_REMOTE_SNAPSHOTS) return;
307
+ // 升序最末 N 个保留;刚 push 的快照(createdAt 最新)必在其中,防御性也把它计入保留集
308
+ const keep = new Set(metas.slice(-MAX_REMOTE_SNAPSHOTS).map((m) => m.id));
309
+ keep.add(pushedId);
310
+ for (const m of metas) {
311
+ if (keep.has(m.id)) continue;
312
+ try {
313
+ await this.transport.delete(m.id);
314
+ } catch (err) {
315
+ const reason = err instanceof Error ? err.message : String(err);
316
+ warnings.push(this.msg('sync.pruneDeleteFailed', { id: m.id, reason }));
317
+ }
318
+ }
319
+ }
320
+
278
321
  /**
279
322
  * pull:拉取远端最新(或指定)快照 → 过滤 portable 分区 → 转临时标准 ZIP →
280
323
  * 复用 Importer 预览流程(analyzeImport/createImportPlan)产出差异报告。
@@ -60,3 +60,19 @@ test('conflict-view: viewItems 提供视图数据(React 绑定)', () => {
60
60
  assert.equal(first.resolution, 'useImported');
61
61
  assert.ok(first.currentSummary !== undefined);
62
62
  });
63
+
64
+ test('conflict-view: 批量决策(ConflictList 按钮语义)遍历所有冲突并清空未决', () => {
65
+ // 「全部保留当前配置」
66
+ const keepAll = new ConflictCollector(conflictPlan());
67
+ for (const item of keepAll.conflicts) keepAll.resolve(item.id, 'keepCurrent');
68
+ assert.equal(keepAll.hasUnresolved, false);
69
+ assert.equal(keepAll.toResolutions()['settings:a'], 'keepCurrent');
70
+ assert.equal(keepAll.toResolutions()['plugin:c'], 'keepCurrent');
71
+
72
+ // 「全部使用备份配置」
73
+ const useAll = new ConflictCollector(conflictPlan());
74
+ for (const item of useAll.conflicts) useAll.resolve(item.id, 'useImported');
75
+ assert.equal(useAll.hasUnresolved, false);
76
+ assert.equal(useAll.toResolutions()['settings:a'], 'useImported');
77
+ assert.equal(useAll.toResolutions()['plugin:c'], 'useImported');
78
+ });