dsh-config-manager 0.2.2 → 0.2.4
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/client.d.ts +13 -13
- package/lib/client.js +551 -554
- package/lib/core/messages-core.js +2 -2
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -1
- package/lib/routes/messages.d.ts +2 -2
- package/lib/routes/messages.js +4 -4
- package/lib/sync/git/git-transport.d.ts +46 -2
- package/lib/sync/git/git-transport.js +277 -64
- package/lib/utils/git-fast-forward.d.ts +7 -2
- package/lib/utils/git-fast-forward.js +8 -3
- package/package.json +1 -1
- package/src/client/history/history-locales.ts +2 -2
- package/src/client/locales.ts +4 -4
- package/src/client/market/MyConfigsView.tsx +3 -3
- package/src/client/market/market-locales.ts +4 -4
- package/src/client/sync/SyncPanel.tsx +2 -2
- package/src/client/sync/SyncProbeRow.tsx +3 -2
- package/src/client/sync/sync-locales.ts +21 -19
- package/src/core/messages-core.ts +2 -2
- package/src/index.ts +1 -1
- package/src/routes/messages.ts +4 -4
- package/src/sync/git/git-probe.test.ts +5 -1
- package/src/sync/git/git-transport.test.ts +184 -8
- package/src/sync/git/git-transport.ts +278 -70
- package/src/utils/git-fast-forward.ts +8 -3
|
@@ -111,9 +111,10 @@ export function SyncProbeRow({ api, t, body, remoteReady, busy, presentation = '
|
|
|
111
111
|
|
|
112
112
|
return (
|
|
113
113
|
<>
|
|
114
|
-
{/* action 形态:直接摊进调用方自己的动作行(不再套一层 .actionRow),结果走 toast
|
|
114
|
+
{/* action 形态:直接摊进调用方自己的动作行(不再套一层 .actionRow),结果走 toast。
|
|
115
|
+
动作行里只留按钮:与同排其它按钮并排时,ⓘ 会被读成该按钮的开关(issue 121 用户要求移除)。 */}
|
|
115
116
|
{actionMode
|
|
116
|
-
? <>{button}
|
|
117
|
+
? <>{button}</>
|
|
117
118
|
: <div className={css.actionRow}>{button}{hintNode}</div>}
|
|
118
119
|
{!actionMode && error !== null && (
|
|
119
120
|
<ErrorBanner
|
|
@@ -20,8 +20,8 @@ export const zh = {
|
|
|
20
20
|
'config.tokenMissing': '未配置令牌:可拉取公开仓库,无法推送',
|
|
21
21
|
'config.tokenPlaceholder': 'ghp_…(可选)',
|
|
22
22
|
// 配置保存(自动保存 + 显式保存按钮)
|
|
23
|
-
'config.save': '
|
|
24
|
-
'config.saveHint': '
|
|
23
|
+
'config.save': '立即保存',
|
|
24
|
+
'config.saveHint': '表单改动会自动保存:密码/token 安全写入 DSH credentials,不会写入同步文件或日志。',
|
|
25
25
|
// 通道选择器
|
|
26
26
|
'channel.title': '同步通道',
|
|
27
27
|
'channel.git': 'Git 私有仓库',
|
|
@@ -31,7 +31,7 @@ export const zh = {
|
|
|
31
31
|
'channel.perChannelHint': '每条通道(Git / WebDAV / 对象存储 / Gist)的自动同步、同步模式、加密与远端快照各自独立配置。',
|
|
32
32
|
// 通道入口卡(弹窗驱动:点按钮 → 弹窗内配置通道)
|
|
33
33
|
'channel.open': '配置…',
|
|
34
|
-
'channel.openHint': '远程同步通过「同步通道」进行:Git 私有仓库或 WebDAV
|
|
34
|
+
'channel.openHint': '远程同步通过「同步通道」进行:Git 私有仓库或 WebDAV 服务器。',
|
|
35
35
|
// 断开通道配置(用户实测要求:配置过就必须能删掉,否则打不通的通道会永久占位)
|
|
36
36
|
'channel.expand': '展开通道详情',
|
|
37
37
|
'channel.collapse': '收起通道详情',
|
|
@@ -125,9 +125,11 @@ export const zh = {
|
|
|
125
125
|
'push.title': '推送结果',
|
|
126
126
|
'sections.title': '同步分区',
|
|
127
127
|
'warnings.title': '分区告警',
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
'syncflow.
|
|
128
|
+
// 从远端合并到本机(方案 A:拉取 → 逐项确认 → apply-items)
|
|
129
|
+
// 名字必须写明方向(issue 121):这条链路只下行、绝不上传 —— 叫「同步」会被读成双向。
|
|
130
|
+
'syncflow.title': '从远端合并到本机',
|
|
131
|
+
'syncflow.button': '从远端合并到本机',
|
|
132
|
+
// 方向说明(ⓘ,挂在动作行):只下行 + 双向该去哪儿;常驻成行会与按钮名重复(DESIGN.md §7)
|
|
131
133
|
// 方案 A:有基线且三方合并全部 skip = 远端与本机一致,不必同步
|
|
132
134
|
'syncflow.upToDate': '已是最新,无需同步',
|
|
133
135
|
'syncflow.syncing': '正在同步…',
|
|
@@ -177,7 +179,7 @@ export const zh = {
|
|
|
177
179
|
'syncflow.remoteEmptyGuide': '远端还没有任何快照:这是首次同步,先点「推送到远端」把本机配置推上去(推送会创建第一个快照),之后才能拉取。',
|
|
178
180
|
// 同步模式(默认快速导出 / 高级自定义导出)
|
|
179
181
|
'mode.title': '同步分区',
|
|
180
|
-
'mode.hint': '
|
|
182
|
+
'mode.hint': '同步范围 = 勾选的分区集合(默认已勾选推荐分区,可随时改)。',
|
|
181
183
|
'mode.selectedCount': '已勾选 {n} 个',
|
|
182
184
|
'mode.sectionsHint': '默认可同步的是可移植分区;平台相关分区(MCP 服务器)与设备相关分区(插件文件、凭据状态、历史会话)只有你在这里主动勾选才参与同步。',
|
|
183
185
|
'mode.atLeastOne': '请至少勾选一个同步分区',
|
|
@@ -216,7 +218,7 @@ export const zh = {
|
|
|
216
218
|
'mode.includeSecretsHint': '把真实凭据值(~/.dsh/.credentials.yaml)写入加密快照,可在另一台机器上随同步一并写回本机凭据;勾选时自动选中加密,密钥绝不进入未加密快照。',
|
|
217
219
|
'mode.encryptAutosyncNotice': '加密快照仅通过手动推送/拉取使用;自动同步无密码,遇到加密快照会跳过并在历史中提示。',
|
|
218
220
|
'mode.decryptPassword': '解密密码(加密快照拉取/同步用,可选)',
|
|
219
|
-
'mode.decryptPasswordHint': '输入后保存在本机凭据库,下次拉取自动使用;只有拉取的快照确实加密时才会用到 ——
|
|
221
|
+
'mode.decryptPasswordHint': '输入后保存在本机凭据库,下次拉取自动使用;只有拉取的快照确实加密时才会用到 —— 未加密的备份不会使用密码。',
|
|
220
222
|
'mode.decryptPasswordSaved': '已保存到本机',
|
|
221
223
|
'mode.decryptPasswordPlaceholder': '已保存(留空即沿用)',
|
|
222
224
|
'mode.clearSavedPassword': '删除已保存密码',
|
|
@@ -257,7 +259,7 @@ export const zh = {
|
|
|
257
259
|
// 同步历史(含自动同步记录)
|
|
258
260
|
'history.title': '同步历史',
|
|
259
261
|
'history.empty': '尚无同步历史',
|
|
260
|
-
'history.emptyHint': '
|
|
262
|
+
'history.emptyHint': '完成首次推送 / 从远端合并 / 自动同步后,这里会显示记录。',
|
|
261
263
|
'history.colTime': '时间',
|
|
262
264
|
'history.colDetail': '详情',
|
|
263
265
|
'history.kindAutosync': '自动同步',
|
|
@@ -304,7 +306,7 @@ export const zh = {
|
|
|
304
306
|
'toast.pushPreviewFailed': '推送预览失败',
|
|
305
307
|
'toast.pushFailed': '推送失败',
|
|
306
308
|
'toast.pushDone': '推送完成',
|
|
307
|
-
'toast.syncStartFailed': '
|
|
309
|
+
'toast.syncStartFailed': '从远端合并失败',
|
|
308
310
|
'toast.autosyncUpdateFailed': '自动同步设置更新失败',
|
|
309
311
|
'toast.autosyncUpdated': '自动同步设置已更新',
|
|
310
312
|
'toast.snapshotsLoadFailed': '读取远端快照失败',
|
|
@@ -474,8 +476,8 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
474
476
|
'config.tokenMissing': 'No token: public repos can be pulled, but pushing will fail',
|
|
475
477
|
'config.tokenPlaceholder': 'ghp_… (optional)',
|
|
476
478
|
// Config save (auto-save + explicit save button)
|
|
477
|
-
'config.save': 'Save
|
|
478
|
-
'config.saveHint': 'Form changes are saved automatically
|
|
479
|
+
'config.save': 'Save now',
|
|
480
|
+
'config.saveHint': 'Form changes are saved automatically: password/token goes securely into DSH credentials, never into sync files or logs.',
|
|
479
481
|
'channel.title': 'Sync Channel',
|
|
480
482
|
'channel.git': 'Git private repo',
|
|
481
483
|
'channel.webdav': 'WebDAV server',
|
|
@@ -484,7 +486,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
484
486
|
'channel.perChannelHint': 'Auto sync, sync mode, encryption and remote snapshots are configured independently for each channel (Git / WebDAV / object storage / Gist).',
|
|
485
487
|
// Channel entry card (dialog-driven: click the button → configure the channel in the dialog)
|
|
486
488
|
'channel.open': 'Configure…',
|
|
487
|
-
'channel.openHint': 'Remote sync runs through a sync channel: a private Git repository or a WebDAV server.
|
|
489
|
+
'channel.openHint': 'Remote sync runs through a sync channel: a private Git repository or a WebDAV server.',
|
|
488
490
|
// Disconnect channel config (user report: once configured there was no way out)
|
|
489
491
|
'channel.expand': 'Expand channel details',
|
|
490
492
|
'channel.collapse': 'Collapse channel details',
|
|
@@ -573,8 +575,8 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
573
575
|
'push.title': 'Push Result',
|
|
574
576
|
'sections.title': 'Synced Sections',
|
|
575
577
|
'warnings.title': 'Section Warnings',
|
|
576
|
-
'syncflow.title': '
|
|
577
|
-
'syncflow.button': '
|
|
578
|
+
'syncflow.title': 'Merge from remote',
|
|
579
|
+
'syncflow.button': 'Merge from remote',
|
|
578
580
|
// Plan A: with a baseline, an all-skip three-way merge means local and remote already match
|
|
579
581
|
'syncflow.upToDate': 'Already up to date; nothing to sync',
|
|
580
582
|
'syncflow.syncing': 'Syncing…',
|
|
@@ -623,7 +625,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
623
625
|
'syncflow.remoteEmptyGuide': 'No remote snapshots yet: this is the first sync — click "Push to remote" to upload your local config (that push creates the first snapshot); pulling only works afterwards.',
|
|
624
626
|
// Sync mode (default quick export / advanced custom export)
|
|
625
627
|
'mode.title': 'Sync Sections',
|
|
626
|
-
'mode.hint': '
|
|
628
|
+
'mode.hint': 'Sync scope = the ticked set of sections (recommended sections are ticked by default and can be changed any time).',
|
|
627
629
|
'mode.selectedCount': '{n} selected',
|
|
628
630
|
'mode.sectionsHint': 'Portable sections sync by default; platform-dependent sections (MCP servers) and device-specific sections (plugin files, credentials status, sessions) only participate when you explicitly tick them here.',
|
|
629
631
|
'mode.atLeastOne': 'Select at least one section to sync',
|
|
@@ -662,7 +664,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
662
664
|
'mode.includeSecretsHint': 'Write real credential values (~/.dsh/.credentials.yaml) into the encrypted snapshot so another machine can restore them with the sync; checking this auto-selects encryption, and secrets never enter a plaintext snapshot.',
|
|
663
665
|
'mode.encryptAutosyncNotice': 'Encrypted snapshots are only produced/consumed by manual push/pull; auto sync has no password, so it skips encrypted snapshots and reports them in history.',
|
|
664
666
|
'mode.decryptPassword': 'Decryption password (for encrypted snapshot pull/sync, optional)',
|
|
665
|
-
'mode.decryptPasswordHint': 'Once entered it is saved in the local credential store and reused automatically; it is only ever used when the pulled snapshot is actually encrypted — plaintext backups never use the password.
|
|
667
|
+
'mode.decryptPasswordHint': 'Once entered it is saved in the local credential store and reused automatically; it is only ever used when the pulled snapshot is actually encrypted — plaintext backups never use the password.',
|
|
666
668
|
'mode.decryptPasswordSaved': 'Saved locally',
|
|
667
669
|
'mode.decryptPasswordPlaceholder': 'Saved (leave empty to reuse)',
|
|
668
670
|
'mode.clearSavedPassword': 'Delete saved password',
|
|
@@ -701,7 +703,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
701
703
|
'autosync.lastMessage': 'Last message',
|
|
702
704
|
'history.title': 'Sync History',
|
|
703
705
|
'history.empty': 'No sync history yet',
|
|
704
|
-
'history.emptyHint': 'Records appear here after your first push /
|
|
706
|
+
'history.emptyHint': 'Records appear here after your first push / merge from remote / auto sync.',
|
|
705
707
|
'history.colTime': 'Time',
|
|
706
708
|
'history.colDetail': 'Detail',
|
|
707
709
|
'history.kindAutosync': 'Auto Sync',
|
|
@@ -745,7 +747,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
745
747
|
'toast.pushPreviewFailed': 'Failed to build push preview',
|
|
746
748
|
'toast.pushFailed': 'Push failed',
|
|
747
749
|
'toast.pushDone': 'Push completed',
|
|
748
|
-
'toast.syncStartFailed': '
|
|
750
|
+
'toast.syncStartFailed': 'Merge from remote failed',
|
|
749
751
|
'toast.autosyncUpdateFailed': 'Failed to update auto-sync settings',
|
|
750
752
|
'toast.autosyncUpdated': 'Auto-sync settings updated',
|
|
751
753
|
'toast.snapshotsLoadFailed': 'Failed to load remote snapshots',
|
|
@@ -67,7 +67,7 @@ export const zh = {
|
|
|
67
67
|
|
|
68
68
|
// ---------- run-registry ----------
|
|
69
69
|
'core.run.autosyncBusy': '自动同步已在进行中',
|
|
70
|
-
'core.run.syncApplyBusy': '
|
|
70
|
+
'core.run.syncApplyBusy': '正在从远端合并',
|
|
71
71
|
'core.run.backupScheduleBusy': '定时备份已在进行中',
|
|
72
72
|
'core.run.restoreBusy': '快照恢复已在进行中(请等待当前恢复完成)',
|
|
73
73
|
'core.run.recoveryBusy': '恢复/回滚已在进行中(请等待当前恢复完成)',
|
|
@@ -381,7 +381,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
381
381
|
|
|
382
382
|
// ---------- run-registry ----------
|
|
383
383
|
'core.run.autosyncBusy': 'Automatic sync is already in progress',
|
|
384
|
-
'core.run.syncApplyBusy': '
|
|
384
|
+
'core.run.syncApplyBusy': 'Merge from remote is already in progress',
|
|
385
385
|
'core.run.backupScheduleBusy': 'Scheduled backup is already in progress',
|
|
386
386
|
'core.run.restoreBusy': 'Snapshot restore is already in progress (wait for the current restore to finish)',
|
|
387
387
|
'core.run.recoveryBusy': 'Recovery/rollback is already in progress (wait for the current recovery to finish)',
|
package/src/index.ts
CHANGED
|
@@ -180,7 +180,7 @@ export const name = 'config-manager'
|
|
|
180
180
|
export const inject = ['settings', 'credentials']
|
|
181
181
|
|
|
182
182
|
/** Plugin version, kept in sync with package.json ("version"). */
|
|
183
|
-
export const PLUGIN_VERSION = '0.2.
|
|
183
|
+
export const PLUGIN_VERSION = '0.2.4'
|
|
184
184
|
|
|
185
185
|
/** Plugin own package name — excluded from its own exported plugins list. */
|
|
186
186
|
const PLUGIN_NAME = 'dsh-config-manager'
|
package/src/routes/messages.ts
CHANGED
|
@@ -41,13 +41,13 @@ export const zh = {
|
|
|
41
41
|
'route.syncPreviewFailed': '同步预览失败',
|
|
42
42
|
'route.syncSessionExpired': '同步会话不存在或已过期,请重新拉取预览',
|
|
43
43
|
'route.syncConflictResolutionRequired': '冲突项 {id} 必须提供 resolution(useRemote/keepLocal/skip)',
|
|
44
|
-
'route.syncApplySummary': '
|
|
44
|
+
'route.syncApplySummary': '从远端合并:{count} 项{status}{ineffective}',
|
|
45
45
|
'route.syncRollbackIncomplete': '(回滚未完成)',
|
|
46
46
|
'route.syncRolledBack': '(已回滚)',
|
|
47
47
|
'route.syncIneffectiveSuffix': '({count} 项未生效)',
|
|
48
48
|
'route.syncApplyPartial': '同步应用部分成功:有项未生效(见告警明细)',
|
|
49
49
|
'route.syncApplyFailed': '同步应用未完全成功',
|
|
50
|
-
'route.syncRollbackSummary': '
|
|
50
|
+
'route.syncRollbackSummary': '从远端合并回滚({id})',
|
|
51
51
|
// ---------- 事故恢复 ----------
|
|
52
52
|
'route.recoverySessionRepairRollback': '会话修复回滚',
|
|
53
53
|
'route.recoverySessionRepairDropped': '会话修复(丢弃重放重复行 {count} 行)',
|
|
@@ -82,13 +82,13 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
82
82
|
'route.syncPreviewFailed': 'Sync preview failed',
|
|
83
83
|
'route.syncSessionExpired': 'The sync session does not exist or has expired; pull a fresh preview',
|
|
84
84
|
'route.syncConflictResolutionRequired': 'Conflict item {id} must provide a resolution (useRemote/keepLocal/skip)',
|
|
85
|
-
'route.syncApplySummary': '
|
|
85
|
+
'route.syncApplySummary': 'Merge from remote: {count} item(s){status}{ineffective}',
|
|
86
86
|
'route.syncRollbackIncomplete': ' (rollback incomplete)',
|
|
87
87
|
'route.syncRolledBack': ' (rolled back)',
|
|
88
88
|
'route.syncIneffectiveSuffix': ' ({count} item(s) had no effect)',
|
|
89
89
|
'route.syncApplyPartial': 'Sync apply partially succeeded: some items had no effect (see the warning details)',
|
|
90
90
|
'route.syncApplyFailed': 'Sync apply did not fully succeed',
|
|
91
|
-
'route.syncRollbackSummary': '
|
|
91
|
+
'route.syncRollbackSummary': 'Merge from remote rollback ({id})',
|
|
92
92
|
'route.recoverySessionRepairRollback': 'Session repair rollback',
|
|
93
93
|
'route.recoverySessionRepairDropped': 'Session repair (dropped {count} replayed duplicate row(s))',
|
|
94
94
|
'route.recoverySessionRepairRejected': 'Session repair was rejected: {reason}',
|
|
@@ -48,7 +48,11 @@ test('git probe:匿名 ls-remote 成功 → 连接正常,且**只**发这一
|
|
|
48
48
|
assert.equal(result.snapshotCount, undefined, '不得报快照数(统计它需要 fetch 工作副本)');
|
|
49
49
|
assert.deepEqual(result.createdCollections ?? [], [], 'git 通道不在远端建目录');
|
|
50
50
|
assert.equal(calls.length, 1, '探针只准发一条 ls-remote');
|
|
51
|
-
|
|
51
|
+
// 前置的 `-c <key=value>` 是网络低速上限(issue #122),不属于命令本体
|
|
52
|
+
const argv = calls[0]!;
|
|
53
|
+
let i = 0;
|
|
54
|
+
while (argv[i] === '-c') i += 2;
|
|
55
|
+
assert.deepEqual(argv.slice(i), ['ls-remote', REPO]);
|
|
52
56
|
});
|
|
53
57
|
|
|
54
58
|
test('git probe:匿名被拒、带凭据成功 → 连接正常(私有仓库 + 凭据可用)', async () => {
|
|
@@ -11,7 +11,7 @@ import os from 'node:os';
|
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import { execFile } from 'node:child_process';
|
|
13
13
|
|
|
14
|
-
import { clearInspectFactsCache, GitTransport, GitTransportError } from './git-transport.ts';
|
|
14
|
+
import { clearInspectFactsCache, defaultGitExec, GitTransport, GitTransportError } from './git-transport.ts';
|
|
15
15
|
import type { GitExecFn, GitExecResult, GitTransportOptions } from './git-transport.ts';
|
|
16
16
|
import { encryptSectionsPayload } from '../snapshot-crypto.ts';
|
|
17
17
|
import { computeSnapshotMeta, DEFAULT_SYNC_TIMEOUT_MS, isEncryptedSections, SyncTransportError } from '../transport.ts';
|
|
@@ -119,6 +119,18 @@ function joinedArgs(calls: CallRecord[], needle: string): string[] {
|
|
|
119
119
|
return calls.filter((c) => c.args.join(' ').includes(needle)).map((c) => c.args.join(' '));
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
/**
|
|
123
|
+
* 拉取命令判定(issue #122):fetch 与 origin 之间现在会插 `--no-tags`/`--filter=blob:none`,
|
|
124
|
+
* 所以不能再按 'fetch origin' 子串匹配。
|
|
125
|
+
*/
|
|
126
|
+
function isFetchArgs(args: string[]): boolean {
|
|
127
|
+
return args.includes('fetch') && args.includes('origin');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function fetchCalls(calls: CallRecord[]): string[] {
|
|
131
|
+
return calls.filter((c) => isFetchArgs(c.args)).map((c) => c.args.join(' '));
|
|
132
|
+
}
|
|
133
|
+
|
|
122
134
|
/** 构造加密快照(sections 为 EncryptedSections 密文载荷;含文件分区以覆盖字节往返)。 */
|
|
123
135
|
async function encryptedSnapshot(overrides: Partial<SyncSnapshot> = {}): Promise<SyncSnapshot> {
|
|
124
136
|
const base = sampleSnapshot();
|
|
@@ -896,7 +908,7 @@ function pullScriptedExec(calls: CallRecord[], pullResults: GitExecResult[]): Gi
|
|
|
896
908
|
const joined = args.join(' ');
|
|
897
909
|
if (joined.includes('rev-parse') && joined.includes('--abbrev-ref')) return { stdout: 'main', stderr: '', code: 0 };
|
|
898
910
|
if (joined.includes('rev-parse')) return { stdout: 'sha', stderr: '', code: 0 };
|
|
899
|
-
if (
|
|
911
|
+
if (isFetchArgs(args)) {
|
|
900
912
|
const r = pullResults[Math.min(i, pullResults.length - 1)]!;
|
|
901
913
|
i += 1;
|
|
902
914
|
return r;
|
|
@@ -916,7 +928,7 @@ test('t24:幂等读(list)遇瞬时网络故障 → 指数退避重试后
|
|
|
916
928
|
workDir: dir, exec, retry: { attempts: 3, baseDelayMs: 1, sleep: async () => {} },
|
|
917
929
|
}));
|
|
918
930
|
assert.deepEqual(await transport.list(), [], '重试后应成功(空远端)');
|
|
919
|
-
assert.equal(
|
|
931
|
+
assert.equal(fetchCalls(calls).length, 2, '第一次瞬时失败 → 重试一次即成功');
|
|
920
932
|
});
|
|
921
933
|
|
|
922
934
|
test('t24:重试耗尽后上抛带分类的错误(network / retryable=true),尝试次数 = attempts', async (t) => {
|
|
@@ -933,7 +945,7 @@ test('t24:重试耗尽后上抛带分类的错误(network / retryable=true
|
|
|
933
945
|
assert.equal(err.retryable, true);
|
|
934
946
|
return true;
|
|
935
947
|
});
|
|
936
|
-
assert.equal(
|
|
948
|
+
assert.equal(fetchCalls(calls).length, 3, 'attempts=3 → 恰好 3 次尝试');
|
|
937
949
|
});
|
|
938
950
|
|
|
939
951
|
test('t24:不可重试错误(仓库不存在 / 鉴权失败)→ 只尝试一次且分类正确', async (t) => {
|
|
@@ -954,7 +966,7 @@ test('t24:不可重试错误(仓库不存在 / 鉴权失败)→ 只尝试
|
|
|
954
966
|
assert.equal(err.retryable, false, '不可重试: ' + stderr);
|
|
955
967
|
return true;
|
|
956
968
|
});
|
|
957
|
-
assert.equal(
|
|
969
|
+
assert.equal(fetchCalls(calls).length, 1, '不可重试错误不得重试: ' + stderr);
|
|
958
970
|
}
|
|
959
971
|
});
|
|
960
972
|
|
|
@@ -968,7 +980,9 @@ test('远端同步命令序列:显式 refspec fetch + merge --ff-only,绝不
|
|
|
968
980
|
const joined = calls.map((c) => c.args.join(' '));
|
|
969
981
|
assert.ok(!joined.some((j) => /^pull(\s|$)/.test(j)), '不得再执行 git pull: ' + joined.join(' | '));
|
|
970
982
|
// 网络命令前面会挂 credential helper 的 -c 参数,所以按「末尾 3 个参数」判命令本身
|
|
971
|
-
|
|
983
|
+
// issue #122:fetch 与 origin 之间还会插 `--no-tags`(partial clone 时另有 `--filter=blob:none`),
|
|
984
|
+
// 所以按「含 fetch / 含 origin / 末尾是显式 refspec」判定,而不是钉死参数个数
|
|
985
|
+
const fetched = calls.some((c) => isFetchArgs(c.args) && c.args.at(-1) === '+refs/heads/main:refs/remotes/origin/main');
|
|
972
986
|
assert.ok(fetched, '必须显式 refspec fetch: ' + joined.join(' | '));
|
|
973
987
|
assert.ok(joined.includes('merge --ff-only origin/main'), '必须显式远端分支快进合并: ' + joined.join(' | '));
|
|
974
988
|
});
|
|
@@ -981,7 +995,7 @@ test('远端还没有同名分支(空仓库 / 分支被改名)→ 与「无
|
|
|
981
995
|
const joined = args.join(' ');
|
|
982
996
|
if (joined.includes('--abbrev-ref')) return { stdout: 'main', stderr: '', code: 0 };
|
|
983
997
|
if (joined.includes('rev-parse')) return { stdout: 'sha', stderr: '', code: 0 };
|
|
984
|
-
if (
|
|
998
|
+
if (isFetchArgs(args)) {
|
|
985
999
|
return { stdout: '', stderr: "fatal: couldn't find remote ref refs/heads/main", code: 128 };
|
|
986
1000
|
}
|
|
987
1001
|
return { stdout: '', stderr: '', code: 0 };
|
|
@@ -991,6 +1005,168 @@ test('远端还没有同名分支(空仓库 / 分支被改名)→ 与「无
|
|
|
991
1005
|
assert.equal(calls.filter((c) => c.args.includes('merge')).length, 0, '没 fetch 到东西就不该 merge');
|
|
992
1006
|
});
|
|
993
1007
|
|
|
1008
|
+
/* ---------------- issue #122:远端仓库远大于同步内容时不许卡死 ---------------- */
|
|
1009
|
+
|
|
1010
|
+
const SHA_A = 'a'.repeat(40);
|
|
1011
|
+
const SHA_B = 'b'.repeat(40);
|
|
1012
|
+
|
|
1013
|
+
test('issue #122:远端 sha 与本地跟踪引用一致且已合并 → 整轮零 fetch、零 merge(只花一次 ls-remote)', async (t) => {
|
|
1014
|
+
const dir = await makeGitWorkDir(t);
|
|
1015
|
+
const calls: CallRecord[] = [];
|
|
1016
|
+
const exec: GitExecFn = async (_cmd, args) => {
|
|
1017
|
+
calls.push({ cmd: 'git', args });
|
|
1018
|
+
const joined = args.join(' ');
|
|
1019
|
+
if (joined.includes('--abbrev-ref')) return { stdout: 'main', stderr: '', code: 0 };
|
|
1020
|
+
if (joined.includes('ls-remote')) return { stdout: `${SHA_A}\trefs/heads/main\n`, stderr: '', code: 0 };
|
|
1021
|
+
if (joined.includes('rev-parse')) return { stdout: SHA_A, stderr: '', code: 0 };
|
|
1022
|
+
return { stdout: '', stderr: '', code: 0 };
|
|
1023
|
+
};
|
|
1024
|
+
const transport = new GitTransport(makeOptions({ workDir: dir, exec }));
|
|
1025
|
+
assert.deepEqual(await transport.list(), []);
|
|
1026
|
+
const trace = calls.map((c) => c.args.join(' ')).join(' | ');
|
|
1027
|
+
assert.equal(fetchCalls(calls).length, 0, '稳态不得发 fetch: ' + trace);
|
|
1028
|
+
assert.equal(calls.filter((c) => c.args.includes('merge')).length, 0, '稳态不得 merge: ' + trace);
|
|
1029
|
+
assert.equal(joinedArgs(calls, 'ls-remote').length, 1, '稳态只允许一次 ls-remote: ' + trace);
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
test('issue #122:refs 已是最新但 HEAD 落后(上次 merge 没跑完)→ 跳过 fetch、仍本地 merge', async (t) => {
|
|
1033
|
+
const dir = await makeGitWorkDir(t);
|
|
1034
|
+
const calls: CallRecord[] = [];
|
|
1035
|
+
const exec: GitExecFn = async (_cmd, args) => {
|
|
1036
|
+
calls.push({ cmd: 'git', args });
|
|
1037
|
+
const joined = args.join(' ');
|
|
1038
|
+
if (joined.includes('--abbrev-ref')) return { stdout: 'main', stderr: '', code: 0 };
|
|
1039
|
+
if (joined.includes('ls-remote')) return { stdout: `${SHA_A}\trefs/heads/main\n`, stderr: '', code: 0 };
|
|
1040
|
+
// 远端跟踪引用已指向远端最新,但 HEAD 还停在旧提交
|
|
1041
|
+
if (joined.includes('refs/remotes/origin/main')) return { stdout: SHA_A, stderr: '', code: 0 };
|
|
1042
|
+
if (joined.includes('rev-parse')) return { stdout: SHA_B, stderr: '', code: 0 };
|
|
1043
|
+
return { stdout: '', stderr: '', code: 0 };
|
|
1044
|
+
};
|
|
1045
|
+
const transport = new GitTransport(makeOptions({ workDir: dir, exec }));
|
|
1046
|
+
assert.deepEqual(await transport.list(), []);
|
|
1047
|
+
assert.equal(fetchCalls(calls).length, 0, '没有新对象可拉 → 不 fetch');
|
|
1048
|
+
assert.equal(joinedArgs(calls, 'merge --ff-only origin/main').length, 1, '本地没合并完 → 必须继续 merge');
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
test('issue #122:远端有新提交 → 正常 fetch + merge,fetch 带 --no-tags(同步仓库不需要 tag)', async (t) => {
|
|
1052
|
+
const dir = await makeGitWorkDir(t);
|
|
1053
|
+
const calls: CallRecord[] = [];
|
|
1054
|
+
const exec: GitExecFn = async (_cmd, args) => {
|
|
1055
|
+
calls.push({ cmd: 'git', args });
|
|
1056
|
+
const joined = args.join(' ');
|
|
1057
|
+
if (joined.includes('--abbrev-ref')) return { stdout: 'main', stderr: '', code: 0 };
|
|
1058
|
+
if (joined.includes('ls-remote')) return { stdout: `${SHA_A}\trefs/heads/main\n`, stderr: '', code: 0 };
|
|
1059
|
+
if (joined.includes('refs/remotes/origin/main')) return { stdout: SHA_B, stderr: '', code: 0 };
|
|
1060
|
+
if (joined.includes('rev-parse')) return { stdout: SHA_B, stderr: '', code: 0 };
|
|
1061
|
+
return { stdout: '', stderr: '', code: 0 };
|
|
1062
|
+
};
|
|
1063
|
+
const transport = new GitTransport(makeOptions({ workDir: dir, exec }));
|
|
1064
|
+
assert.deepEqual(await transport.list(), []);
|
|
1065
|
+
const fetches = fetchCalls(calls);
|
|
1066
|
+
assert.equal(fetches.length, 1, '远端有新提交 → 必须 fetch: ' + calls.map((c) => c.args.join(' ')).join(' | '));
|
|
1067
|
+
assert.ok(fetches[0]!.includes('--no-tags'), 'fetch 必须带 --no-tags: ' + fetches[0]!);
|
|
1068
|
+
assert.ok(!fetches[0]!.includes('--filter='), '非 partial clone 不得加 blob 过滤: ' + fetches[0]!);
|
|
1069
|
+
assert.equal(joinedArgs(calls, 'merge --ff-only origin/main').length, 1, 'fetch 之后必须 ff 合并');
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
test('issue #122:partial clone 的 fetch 保持 --filter=blob:none(不因过滤丢失后续 checkout 需要的 blob)', async (t) => {
|
|
1073
|
+
const dir = await makeGitWorkDir(t);
|
|
1074
|
+
const calls: CallRecord[] = [];
|
|
1075
|
+
const exec: GitExecFn = async (_cmd, args) => {
|
|
1076
|
+
calls.push({ cmd: 'git', args });
|
|
1077
|
+
const joined = args.join(' ');
|
|
1078
|
+
if (joined.includes('remote.origin.promisor')) return { stdout: 'true\n', stderr: '', code: 0 };
|
|
1079
|
+
if (joined.includes('--abbrev-ref')) return { stdout: 'main', stderr: '', code: 0 };
|
|
1080
|
+
if (joined.includes('ls-remote')) return { stdout: `${SHA_A}\trefs/heads/main\n`, stderr: '', code: 0 };
|
|
1081
|
+
if (joined.includes('refs/remotes/origin/main')) return { stdout: SHA_B, stderr: '', code: 0 };
|
|
1082
|
+
if (joined.includes('rev-parse')) return { stdout: SHA_B, stderr: '', code: 0 };
|
|
1083
|
+
return { stdout: '', stderr: '', code: 0 };
|
|
1084
|
+
};
|
|
1085
|
+
const transport = new GitTransport(makeOptions({ workDir: dir, exec }));
|
|
1086
|
+
assert.deepEqual(await transport.list(), []);
|
|
1087
|
+
const fetches = fetchCalls(calls);
|
|
1088
|
+
assert.equal(fetches.length, 1);
|
|
1089
|
+
assert.ok(fetches[0]!.includes('--filter=blob:none'), 'partial clone 必须保持 blob 过滤: ' + fetches[0]!);
|
|
1090
|
+
});
|
|
1091
|
+
|
|
1092
|
+
test('issue #122:网络子命令一律带低速上限,本地子命令不带', async (t) => {
|
|
1093
|
+
const dir = await makeGitWorkDir(t);
|
|
1094
|
+
const calls: CallRecord[] = [];
|
|
1095
|
+
const transport = new GitTransport(makeOptions({ workDir: dir, exec: pullScriptedExec(calls, [{ stdout: '', stderr: '', code: 0 }]) }));
|
|
1096
|
+
await transport.list();
|
|
1097
|
+
const net = calls.filter((c) => ['clone', 'fetch', 'push', 'ls-remote'].some((sub) => c.args.includes(sub)));
|
|
1098
|
+
assert.ok(net.length > 0, '至少应有 ls-remote/fetch: ' + calls.map((c) => c.args.join(' ')).join(' | '));
|
|
1099
|
+
for (const c of net) {
|
|
1100
|
+
const j = c.args.join(' ');
|
|
1101
|
+
assert.ok(j.includes('http.lowSpeedLimit=1000') && j.includes('http.lowSpeedTime=30'), '网络命令必须带低速上限: ' + j);
|
|
1102
|
+
}
|
|
1103
|
+
const local = calls.filter((c) => c.args.includes('rev-parse'));
|
|
1104
|
+
assert.ok(local.length > 0);
|
|
1105
|
+
for (const c of local) assert.ok(!c.args.join(' ').includes('lowSpeedLimit'), '本地命令不该带 http 配置: ' + c.args.join(' '));
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
test('issue #122:首次 clone 走 partial clone + sparse-checkout,只 materialize 三个同步目录', async (t) => {
|
|
1109
|
+
const dir = await makeTempDir(t); // 空目录 → 走 clone
|
|
1110
|
+
const calls: CallRecord[] = [];
|
|
1111
|
+
const transport = new GitTransport(makeOptions({ workDir: dir, exec: mockExec(calls) }));
|
|
1112
|
+
await transport.list();
|
|
1113
|
+
const clone = calls.find((c) => c.args.includes('clone'));
|
|
1114
|
+
assert.ok(clone !== undefined, '应触发 clone: ' + calls.map((c) => c.args.join(' ')).join(' | '));
|
|
1115
|
+
assert.ok(clone.args.includes('--filter=blob:none'), 'clone 必须 partial(只取 commit+tree): ' + clone.args.join(' '));
|
|
1116
|
+
assert.ok(clone.args.includes('--no-checkout'), 'clone 先不落地文件: ' + clone.args.join(' '));
|
|
1117
|
+
assert.equal(joinedArgs(calls, 'clone').length, 1, '不允许多 clone 一次');
|
|
1118
|
+
const sparse = calls.find((c) => c.args.includes('sparse-checkout'));
|
|
1119
|
+
assert.ok(sparse !== undefined, '必须设置 sparse-checkout');
|
|
1120
|
+
assert.equal(
|
|
1121
|
+
sparse.args.join(' '),
|
|
1122
|
+
'sparse-checkout set --cone snapshots snapshots-encrypted blobs',
|
|
1123
|
+
'sparse 面必须与同步目录同源: ' + sparse.args.join(' '),
|
|
1124
|
+
);
|
|
1125
|
+
assert.equal(calls.filter((c) => c.args.includes('checkout')).length, 1, 'sparse 之后必须 checkout 一次');
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
test('issue #122:老 git 不认 --filter → 回落全量 clone(绝不因优化失败)', async (t) => {
|
|
1129
|
+
const dir = await makeTempDir(t);
|
|
1130
|
+
const calls: CallRecord[] = [];
|
|
1131
|
+
const exec: GitExecFn = async (_cmd, args) => {
|
|
1132
|
+
calls.push({ cmd: 'git', args });
|
|
1133
|
+
if (args.includes('--filter=blob:none')) {
|
|
1134
|
+
return { stdout: '', stderr: "error: unknown option `filter=blob:none'", code: 129 };
|
|
1135
|
+
}
|
|
1136
|
+
return { stdout: '', stderr: '', code: 0 };
|
|
1137
|
+
};
|
|
1138
|
+
const transport = new GitTransport(makeOptions({ workDir: dir, exec }));
|
|
1139
|
+
await transport.list();
|
|
1140
|
+
const clones = calls.filter((c) => c.args.includes('clone'));
|
|
1141
|
+
assert.equal(clones.length, 2, '必须先试 partial、再回落全量: ' + clones.map((c) => c.args.join(' ')).join(' | '));
|
|
1142
|
+
assert.ok(!clones[1]!.args.includes('--filter=blob:none'), '回落那次不得带 --filter');
|
|
1143
|
+
assert.equal(calls.filter((c) => c.args.includes('sparse-checkout')).length, 0, '回落路径不做 sparse');
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
test('issue #122:超时 kill 整棵进程树,且有界收尾(孙进程不再继续跑)', async (t) => {
|
|
1147
|
+
const dir = await makeTempDir(t);
|
|
1148
|
+
const marker = path.join(dir, 'grandchild-alive.txt');
|
|
1149
|
+
// 父进程拉起孙进程后常驻;孙进程 1.5 s 后写标记文件 —— 只杀父进程时它会照写
|
|
1150
|
+
await fs.writeFile(path.join(dir, 'grandchild.cjs'), [
|
|
1151
|
+
"setTimeout(() => { require('node:fs').writeFileSync(process.argv[2], 'alive'); }, 1500);",
|
|
1152
|
+
'',
|
|
1153
|
+
].join('\n'));
|
|
1154
|
+
await fs.writeFile(path.join(dir, 'parent.cjs'), [
|
|
1155
|
+
"const { spawn } = require('node:child_process');",
|
|
1156
|
+
"spawn(process.execPath, [require('node:path').join(__dirname, 'grandchild.cjs'), process.argv[2]], { stdio: 'ignore' });",
|
|
1157
|
+
'setInterval(() => {}, 1000);',
|
|
1158
|
+
'',
|
|
1159
|
+
].join('\n'));
|
|
1160
|
+
const started = Date.now();
|
|
1161
|
+
const res = await defaultGitExec(process.execPath, [path.join(dir, 'parent.cjs'), marker], { timeoutMs: 200 });
|
|
1162
|
+
const elapsed = Date.now() - started;
|
|
1163
|
+
assert.notEqual(res.code, 0, '超时必须是非零退出');
|
|
1164
|
+
assert.match(res.stderr, /timed out after 200ms/, '超时文案必须让分类器判 timeout(瞬时、可重试)');
|
|
1165
|
+
assert.ok(elapsed < 4000, `超时收尾必须有界(实际 ${String(elapsed)}ms)`);
|
|
1166
|
+
await new Promise((resolve) => setTimeout(resolve, 2500));
|
|
1167
|
+
await assert.rejects(fs.stat(marker), '孙进程必须被一起杀掉(否则它会继续写文件/继续下 pack)');
|
|
1168
|
+
});
|
|
1169
|
+
|
|
994
1170
|
test('并发驱动同一工作副本(两个 transport 实例)→ git 调用被工作副本锁串行化', async (t) => {
|
|
995
1171
|
const dir = await makeGitWorkDir(t);
|
|
996
1172
|
let active = 0;
|
|
@@ -1194,7 +1370,7 @@ test('issue #79:Host key verification failed → 可操作指纹指引(含 h
|
|
|
1194
1370
|
calls.push({ cmd: 'git', args });
|
|
1195
1371
|
const joined = args.join(' ');
|
|
1196
1372
|
if (joined.includes('remote get-url')) return { stdout: repoUrl, stderr: '', code: 0 };
|
|
1197
|
-
if (
|
|
1373
|
+
if (isFetchArgs(args)) {
|
|
1198
1374
|
return { stdout: '', stderr: 'Host key verification failed.\nfatal: Could not read from remote repository.', code: 128 };
|
|
1199
1375
|
}
|
|
1200
1376
|
return { stdout: 'sha', stderr: '', code: 0 };
|