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
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
* - 加密快照(EncryptedSections 密文载荷,无法平铺为明文 JSON 分区)走「密文单文件」布局:
|
|
8
8
|
* 整个快照 JSON 提交到 <workDir>/snapshots-encrypted/<id>.json(远端已存密文;
|
|
9
9
|
* 本地工作副本即远端镜像,不产生额外明文审计副本)。
|
|
10
|
-
* - 命令执行走 node:child_process
|
|
11
|
-
*
|
|
10
|
+
* - 命令执行走 node:child_process spawn(数组参数无 shell 注入、无 maxBuffer 截断错误),
|
|
11
|
+
* 超时**整棵进程树**一起 kill(issue #122:只 SIGTERM git.exe 会留下 git-remote-https /
|
|
12
|
+
* index-pack 孤儿继续下载);始终使用系统 PATH 中的 git(固定命令 'git'),可注入 exec 便于测试。
|
|
12
13
|
* - 认证:token 仅从注入的 credentials provider 读取(每次网络操作时 getToken()),
|
|
13
14
|
* 经 git credential helper(store --file=<临时文件>)传给 git —— token 不进入 argv、
|
|
14
15
|
* 不进入 repoUrl、不写入任何同步内容/commit message/日志;临时凭据文件用后即删。
|
|
@@ -17,11 +18,10 @@
|
|
|
17
18
|
* - 契约:同 id 重复 upload = 覆盖(幂等友好:内容无变化时不产生 commit);download 不存在抛错;
|
|
18
19
|
* delete 不存在视为成功。
|
|
19
20
|
*/
|
|
20
|
-
import {
|
|
21
|
+
import { spawn } from 'node:child_process';
|
|
21
22
|
import fs from 'node:fs/promises';
|
|
22
23
|
import os from 'node:os';
|
|
23
24
|
import path from 'node:path';
|
|
24
|
-
import { promisify } from 'node:util';
|
|
25
25
|
import { createSnapshotFs, joinFs } from '../fs.js';
|
|
26
26
|
import { readSnapshotFromDir, SNAPSHOT_BLOB_REFS_SUFFIX, SNAPSHOT_KEEP_CONTENT, SNAPSHOT_KEEP_FILE, SNAPSHOT_MANIFEST_FILE, writeSnapshotToDir } from '../layout.js';
|
|
27
27
|
import { BLOB_SECTIONS, gcBlobs, referencedBlobHashes } from '../blob-store.js';
|
|
@@ -35,7 +35,6 @@ import { SECTION_FILE_PREFIXES } from '../../schema/config.js';
|
|
|
35
35
|
import { zhMsg } from '../../core/messages.js';
|
|
36
36
|
import { withWorkdirLock, WorkdirLockError } from '../../utils/workdir-lock.js';
|
|
37
37
|
import { fastForwardFetchArgs, fastForwardMergeArgs, isMissingRemoteRefError, parseCurrentBranch } from '../../utils/git-fast-forward.js';
|
|
38
|
-
const execFileAsync = promisify(execFile);
|
|
39
38
|
/**
|
|
40
39
|
* 工作副本自检结果的短 TTL 缓存(`inspectWorkDir`,2026-10-27)。
|
|
41
40
|
*
|
|
@@ -97,10 +96,29 @@ const BLOBS_REL = 'blobs';
|
|
|
97
96
|
* 远端分支的顶层出现别的名字 = 这不是本插件的同步仓库,合并它等于把别人的仓库内容搬进工作副本。
|
|
98
97
|
*/
|
|
99
98
|
const ADOPT_ROOT_ALLOW = new Set([SNAPSHOTS_REL, SNAPSHOTS_ENCRYPTED_REL, BLOBS_REL]);
|
|
99
|
+
/**
|
|
100
|
+
* 同步仓库只在这三个顶层目录里写内容 ⇒ partial clone 的 sparse-checkout 面与它同源(issue #122)。
|
|
101
|
+
* 远端仓库比同步内容大几个数量级时(报告者现场:1.8 GB 仓库 / 6 MB 内容),全量 clone 与全量 fetch
|
|
102
|
+
* 都要拉完整对象图,而工作副本真正需要的只有这三个目录。
|
|
103
|
+
*/
|
|
104
|
+
const SYNC_ROOT_DIRS = [...ADOPT_ROOT_ALLOW];
|
|
105
|
+
/**
|
|
106
|
+
* 走网络的 git 子命令:一律附加低速上限(issue #122)。
|
|
107
|
+
*
|
|
108
|
+
* 为什么必须显式设:git 的默认 http 低速门限极宽松,链路被限速/黑洞时一条 fetch 能安静地耗到
|
|
109
|
+
* timeoutMs(120 s),再乘上重试次数 = 用户看到的就是「永久停在正在检查远端变更」。
|
|
110
|
+
* 30 s 内平均速率低于 1 KB/s 即明确失败,交给既有分类器判 timeout(瞬时、可重试)。
|
|
111
|
+
*/
|
|
112
|
+
const NETWORK_SUBCOMMANDS = new Set(['clone', 'fetch', 'push', 'ls-remote', 'pull']);
|
|
113
|
+
const LOW_SPEED_ARGS = ['-c', 'http.lowSpeedLimit=1000', '-c', 'http.lowSpeedTime=30'];
|
|
100
114
|
const DEFAULT_AUTHOR = { name: 'DSH Config Sync', email: 'sync@dsh.local' };
|
|
101
115
|
const DEFAULT_CREDENTIAL_USERNAME = 'oauth2';
|
|
102
116
|
/** 快照 id 安全字符集:字母数字开头,仅 . _ -;防路径穿越与 commit message 注入 */
|
|
103
117
|
const SAFE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
118
|
+
/** git 对象 id(sha1 40 位 / sha256 64 位)—— ls-remote 输出与 rev-parse 结果共用同一判定 */
|
|
119
|
+
const SHA_RE = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/;
|
|
120
|
+
/** 老 git 不认新选项(partial clone 需 ≥2.19、cone sparse-checkout 需 ≥2.25):据此回落全量 clone */
|
|
121
|
+
const UNSUPPORTED_GIT_OPTION_RE = /unknown option|unrecognized argument|unknown switch|is not a git command|unsupported/i;
|
|
104
122
|
/**
|
|
105
123
|
* SSH 主机密钥校验失败(issue #79):git 只回一句 `Host key verification failed`,
|
|
106
124
|
* 用户拿不到任何可操作信息(要不要信任?怎么写 known_hosts?)。识别后换成可操作文案。
|
|
@@ -112,41 +130,132 @@ const SSH_HOST_KEY_RE = /host key verification failed|remote host identification
|
|
|
112
130
|
* 这类失败与网络无关,必须给「改回地址或重建工作副本」的出口,而不是只丢一句原始 stderr。
|
|
113
131
|
*/
|
|
114
132
|
const DIVERGED_HISTORY_RE = /refusing to merge unrelated histories|unrelated histories|not possible to fast-forward/i;
|
|
115
|
-
|
|
133
|
+
/** 超时 kill 之后等 stdio 关闭的宽限:逃出进程组的孙进程可能一直握着管道(issue #122) */
|
|
134
|
+
const EXEC_KILL_GRACE_MS = 5_000;
|
|
135
|
+
/** 单条命令累计输出上限(与旧 execFile maxBuffer 同口径,超限即 kill) */
|
|
136
|
+
const EXEC_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
|
|
137
|
+
/**
|
|
138
|
+
* 默认 git 执行器:spawn + **进程树** kill(issue #122)。
|
|
139
|
+
*
|
|
140
|
+
* 为什么不再用 execFile:它的 timeout 只对 git.exe 自己发 SIGTERM,而真正在下包的是 git.exe 的子进程
|
|
141
|
+
* (git-remote-https / git index-pack)。父进程被杀、孙进程继续写 tmp_pack_*,管道不关闭 ⇒ execFile 的
|
|
142
|
+
* promise 连超时错误都可能不返回 ⇒ 上层「正在检查远端变更...」永久挂起,下一轮重试再起一份,最终 N 份
|
|
143
|
+
* 并发下载同一个 pack。
|
|
144
|
+
* 这里改为:POSIX 上 detached 起独立进程组,超时对**整组**发 SIGKILL;Windows 上 `taskkill /T /F`;
|
|
145
|
+
* 无论 kill 是否成功,宽限期到点都带着已收集输出收尾 —— 绝不把「管道没关」变成上层无限等待。
|
|
146
|
+
*
|
|
147
|
+
* 导出仅供测试直接验证「超时 + 进程树 kill + 有界收尾」(issue #122 的验收面)。
|
|
148
|
+
*/
|
|
149
|
+
export const defaultGitExec = async (cmd, args, opts) => await new Promise((resolve) => {
|
|
150
|
+
const isWin = process.platform === 'win32';
|
|
151
|
+
// issue #120:credential helper 走 shell 且本机没有可用 shell 时,改用 env 注入认证头重试 ——
|
|
152
|
+
// 追加在继承的 process.env 上(env 缺省 = 完全继承,不改变既有行为)
|
|
153
|
+
const env = opts.env === undefined ? undefined : { ...process.env, ...opts.env };
|
|
154
|
+
let stdout = '';
|
|
155
|
+
let stderr = '';
|
|
156
|
+
let bytes = 0;
|
|
157
|
+
let settled = false;
|
|
158
|
+
let timedOut = false;
|
|
159
|
+
let overflow = false;
|
|
160
|
+
let timer = null;
|
|
161
|
+
let graceTimer = null;
|
|
162
|
+
let child = null;
|
|
163
|
+
const settle = (code) => {
|
|
164
|
+
if (settled)
|
|
165
|
+
return;
|
|
166
|
+
settled = true;
|
|
167
|
+
if (timer !== null)
|
|
168
|
+
clearTimeout(timer);
|
|
169
|
+
if (graceTimer !== null)
|
|
170
|
+
clearTimeout(graceTimer);
|
|
171
|
+
let text = stderr;
|
|
172
|
+
if (code !== 0) {
|
|
173
|
+
if (overflow && !/exceed/i.test(text))
|
|
174
|
+
text = `${text}\noutput exceeded ${String(EXEC_MAX_OUTPUT_BYTES)} bytes`;
|
|
175
|
+
if (text.trim() === '')
|
|
176
|
+
text = `git ${args[0] ?? ''} exited with code ${String(code)}`;
|
|
177
|
+
// 超时在 git 的 stderr 里常常是空的:补一句可判定的文字,让 transport.ts 的统一分类器
|
|
178
|
+
// 把它识别成 timeout(瞬时、可重试),而不是 unknown。
|
|
179
|
+
if (timedOut && !/timed out|timeout/i.test(text)) {
|
|
180
|
+
text = `git command timed out after ${String(opts.timeoutMs ?? 0)}ms: ${text}`;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
resolve({ stdout, stderr: text, code });
|
|
184
|
+
};
|
|
185
|
+
const killTree = () => {
|
|
186
|
+
const pid = child?.pid;
|
|
187
|
+
if (pid === undefined)
|
|
188
|
+
return;
|
|
189
|
+
if (isWin) {
|
|
190
|
+
// 只 SIGTERM git.exe 会留下 git-remote-https / index-pack 孤儿(issue #122)⇒ 整棵树强杀
|
|
191
|
+
try {
|
|
192
|
+
spawn('taskkill', ['/pid', String(pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' })
|
|
193
|
+
.on('error', () => { });
|
|
194
|
+
}
|
|
195
|
+
catch { /* 同上 */ }
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
process.kill(-pid, 'SIGKILL'); // 负 pid = 整个进程组(detached 起的子进程是组长)
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
try {
|
|
203
|
+
child?.kill('SIGKILL');
|
|
204
|
+
}
|
|
205
|
+
catch { /* 已退出 */ }
|
|
206
|
+
}
|
|
207
|
+
};
|
|
116
208
|
try {
|
|
117
|
-
|
|
118
|
-
cwd: opts.cwd,
|
|
119
|
-
|
|
120
|
-
maxBuffer: 16 * 1024 * 1024,
|
|
209
|
+
child = spawn(cmd, args, {
|
|
210
|
+
...(opts.cwd === undefined ? {} : { cwd: opts.cwd }),
|
|
211
|
+
...(env === undefined ? {} : { env }),
|
|
121
212
|
windowsHide: true,
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
env: opts.env === undefined ? undefined : { ...process.env, ...opts.env },
|
|
213
|
+
// POSIX:独立进程组,超时才可能连孙进程一起杀;Windows 用 taskkill /T
|
|
214
|
+
detached: !isWin,
|
|
215
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
126
216
|
});
|
|
127
|
-
return { stdout: String(stdout), stderr: String(stderr), code: 0 };
|
|
128
217
|
}
|
|
129
218
|
catch (err) {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
// 超时(execFile 到点 kill)在 git 的 stderr 里常常是空的:补一句可判定的文字,
|
|
133
|
-
// 让 transport.ts 的统一分类器把它识别成 timeout(瞬时、可重试),而不是 unknown。
|
|
134
|
-
const timedOut = e.killed === true || e.signal === 'SIGTERM' || e.code === 'ETIMEDOUT';
|
|
135
|
-
const stderr = timedOut && !/timed out|timeout/i.test(detail)
|
|
136
|
-
? `git command timed out after ${String(opts.timeoutMs ?? 0)}ms: ${detail}`
|
|
137
|
-
: detail;
|
|
138
|
-
return {
|
|
139
|
-
stdout: String(e.stdout ?? ''),
|
|
140
|
-
stderr,
|
|
141
|
-
code: typeof e.code === 'number' ? e.code : 1,
|
|
142
|
-
};
|
|
219
|
+
resolve({ stdout: '', stderr: err instanceof Error ? err.message : String(err), code: 1 });
|
|
220
|
+
return;
|
|
143
221
|
}
|
|
144
|
-
|
|
222
|
+
const collect = (sink) => (chunk) => {
|
|
223
|
+
bytes += chunk.length;
|
|
224
|
+
if (bytes > EXEC_MAX_OUTPUT_BYTES) {
|
|
225
|
+
if (!overflow) {
|
|
226
|
+
overflow = true;
|
|
227
|
+
killTree();
|
|
228
|
+
}
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (sink === 'out')
|
|
232
|
+
stdout += chunk.toString('utf8');
|
|
233
|
+
else
|
|
234
|
+
stderr += chunk.toString('utf8');
|
|
235
|
+
};
|
|
236
|
+
child.stdout?.on('data', collect('out'));
|
|
237
|
+
child.stderr?.on('data', collect('err'));
|
|
238
|
+
child.on('error', (err) => {
|
|
239
|
+
stderr = stderr === '' ? err.message : stderr;
|
|
240
|
+
settle(1);
|
|
241
|
+
});
|
|
242
|
+
child.on('close', (code) => { settle(typeof code === 'number' ? code : 1); });
|
|
243
|
+
if (opts.timeoutMs !== undefined) {
|
|
244
|
+
timer = setTimeout(() => {
|
|
245
|
+
timedOut = true;
|
|
246
|
+
killTree();
|
|
247
|
+
// 进程树 kill 之后管道仍可能被握着(逃出进程组的孙进程)⇒ 宽限期到点强制收尾
|
|
248
|
+
graceTimer = setTimeout(() => { settle(1); }, EXEC_KILL_GRACE_MS);
|
|
249
|
+
}, Math.max(1, opts.timeoutMs));
|
|
250
|
+
}
|
|
251
|
+
});
|
|
145
252
|
/** 实现 SyncTransport 的 git 通道。所有操作前 ensureRepo() 保证工作副本就绪,网络命令带凭据。 */
|
|
146
253
|
export class GitTransport {
|
|
147
254
|
type = 'git';
|
|
148
255
|
o;
|
|
149
256
|
repoReady = false;
|
|
257
|
+
/** 该工作副本是否 partial clone(remote.origin.promisor);null = 尚未判定(issue #122) */
|
|
258
|
+
partialClone = null;
|
|
150
259
|
/**
|
|
151
260
|
* 工作副本锁的可重入深度:list/upload/download/delete 都会经 ensureRepo → reconcileOrigin,
|
|
152
261
|
* rebuildWorkDir → adoptRemoteHistory 也各自嵌套;外层入口持锁,内层直接跑。
|
|
@@ -180,7 +289,7 @@ export class GitTransport {
|
|
|
180
289
|
gitBin: 'git',
|
|
181
290
|
timeoutMs: options.timeoutMs ?? DEFAULT_SYNC_TIMEOUT_MS,
|
|
182
291
|
retry: options.retry ?? {},
|
|
183
|
-
exec: options.exec ??
|
|
292
|
+
exec: options.exec ?? defaultGitExec,
|
|
184
293
|
author: options.author ?? DEFAULT_AUTHOR,
|
|
185
294
|
credentialUsername: options.credentialUsername ?? DEFAULT_CREDENTIAL_USERNAME,
|
|
186
295
|
};
|
|
@@ -489,13 +598,93 @@ export class GitTransport {
|
|
|
489
598
|
await withSyncRetry(async () => {
|
|
490
599
|
if (await fsx.exists(gitDir))
|
|
491
600
|
return;
|
|
492
|
-
await this.
|
|
601
|
+
await this.cloneIntoWorkDir();
|
|
493
602
|
}, this.o.retry);
|
|
494
603
|
// 仓库级提交身份(写入远端历史,可经 author 选项覆盖;不改全局配置)
|
|
495
604
|
await this.runGit(['config', 'user.name', this.o.author.name]);
|
|
496
605
|
await this.runGit(['config', 'user.email', this.o.author.email]);
|
|
497
606
|
this.repoReady = true;
|
|
498
607
|
}
|
|
608
|
+
/**
|
|
609
|
+
* 首次 clone(issue #122):partial clone + sparse-checkout,只落地同步内容。
|
|
610
|
+
*
|
|
611
|
+
* 为什么:同步仓库可能比同步内容大几个数量级(报告者现场 1.8 GB 仓库 / 6 MB 内容)。此前 `git clone`
|
|
612
|
+
* 全量落地,之后每次 fetch 还要拉完整对象图 ⇒ 一次「检查远端变更」就下几百 MB。
|
|
613
|
+
* `--filter=blob:none` 只取 commit+tree,`--no-checkout` 先不落地任何文件,
|
|
614
|
+
* `sparse-checkout set --cone <三个目录>` 之后 checkout 只 materialize 这三个目录的 blob。
|
|
615
|
+
*
|
|
616
|
+
* 老 git 不认这些选项(unknown option)时回落全量 clone —— 行为与修复前完全一致,绝不因优化失败。
|
|
617
|
+
*/
|
|
618
|
+
async cloneIntoWorkDir() {
|
|
619
|
+
const cwd = this.o.workDir;
|
|
620
|
+
this.partialClone = null; // reclone 后重新判定(配置可能变)
|
|
621
|
+
const filteredArgs = ['clone', '--filter=blob:none', '--no-checkout', this.o.repoUrl, '.'];
|
|
622
|
+
const filtered = await this.runGit(filteredArgs, { cwd, withCredential: true, allowNonZero: true });
|
|
623
|
+
if (filtered.code !== 0) {
|
|
624
|
+
if (!UNSUPPORTED_GIT_OPTION_RE.test(filtered.stderr + ' ' + filtered.stdout)) {
|
|
625
|
+
// 真实失败(网络 / 鉴权 / 超时):沿用既有分类与文案(含 issue #120 的凭据通道判定)
|
|
626
|
+
this.raiseGitFailure(filteredArgs, filtered, await this.readTokenOnce(), false);
|
|
627
|
+
}
|
|
628
|
+
// 老 git:回落全量 clone(全量 clone 自带 checkout)
|
|
629
|
+
await clearDirContents(cwd);
|
|
630
|
+
await this.runGit(['clone', this.o.repoUrl, '.'], { cwd, withCredential: true });
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
const branch = await this.currentBranch();
|
|
634
|
+
const head = await this.runGit(['rev-parse', '--verify', '--quiet', 'HEAD'], { allowNonZero: true });
|
|
635
|
+
if (head.code !== 0)
|
|
636
|
+
return; // 空仓库:没有可 checkout 的树,后续 push 会建分支
|
|
637
|
+
const target = branch === '' ? 'HEAD' : branch;
|
|
638
|
+
const sparse = await this.runGit(['sparse-checkout', 'set', '--cone', ...SYNC_ROOT_DIRS], { allowNonZero: true });
|
|
639
|
+
if (sparse.code !== 0) {
|
|
640
|
+
// 老 git 没有 cone 模式:宁可全量落地(修复前行为),也不留下一个空工作树
|
|
641
|
+
await this.runGit(['checkout', target], { allowNonZero: true });
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
// checkout 失败必须可见(空工作树会让上层以为「远端没有快照」)
|
|
645
|
+
await this.runGit(['checkout', target]);
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* 该工作副本是不是 partial clone(`remote.origin.promisor=true`)。
|
|
649
|
+
*
|
|
650
|
+
* 只有 partial clone 才能给 fetch 加 `--filter=blob:none`:对普通全量副本加过滤会让后续 checkout
|
|
651
|
+
* 拿到缺失的 blob。存量副本(1.8 GB 那种)因此只受益于 ls-remote 短路与 `--no-tags`,不会被改坏。
|
|
652
|
+
*/
|
|
653
|
+
async isPartialClone() {
|
|
654
|
+
if (this.partialClone !== null)
|
|
655
|
+
return this.partialClone;
|
|
656
|
+
const res = await this.runGit(['config', '--get', 'remote.origin.promisor'], { allowNonZero: true });
|
|
657
|
+
this.partialClone = res.code === 0 && res.stdout.trim() === 'true';
|
|
658
|
+
return this.partialClone;
|
|
659
|
+
}
|
|
660
|
+
/** 拉取姿态:partial clone 保持 blob 过滤;同步仓库不需要 tag(issue #122) */
|
|
661
|
+
async fetchOptionFlags() {
|
|
662
|
+
const flags = [];
|
|
663
|
+
if (await this.isPartialClone())
|
|
664
|
+
flags.push('--filter=blob:none');
|
|
665
|
+
flags.push('--no-tags');
|
|
666
|
+
return flags;
|
|
667
|
+
}
|
|
668
|
+
/** 远端分支当前的 sha(ls-remote 只取一行 refs);读不到 → null(短路是优化,失败一律回落正常 fetch) */
|
|
669
|
+
async lsRemoteBranchSha(branch) {
|
|
670
|
+
const res = await this.runGit(['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], { withCredential: true, allowNonZero: true });
|
|
671
|
+
if (res.code !== 0)
|
|
672
|
+
return null;
|
|
673
|
+
for (const line of res.stdout.split('\n')) {
|
|
674
|
+
const [sha, ref] = line.trim().split('\t');
|
|
675
|
+
if (ref === `refs/heads/${branch}` && sha !== undefined && SHA_RE.test(sha))
|
|
676
|
+
return sha;
|
|
677
|
+
}
|
|
678
|
+
return null;
|
|
679
|
+
}
|
|
680
|
+
/** 解析引用指向的 sha;读不到 / 不是 sha → null */
|
|
681
|
+
async revParseSha(ref) {
|
|
682
|
+
const res = await this.runGit(['rev-parse', '--verify', '--quiet', ref], { allowNonZero: true });
|
|
683
|
+
if (res.code !== 0)
|
|
684
|
+
return null;
|
|
685
|
+
const sha = res.stdout.trim();
|
|
686
|
+
return SHA_RE.test(sha) ? sha : null;
|
|
687
|
+
}
|
|
499
688
|
/**
|
|
500
689
|
* 让工作副本的 origin 与配置的 repoUrl 保持一致(issue #79)。
|
|
501
690
|
*
|
|
@@ -512,6 +701,7 @@ export class GitTransport {
|
|
|
512
701
|
}
|
|
513
702
|
else if (origin !== '' && origin !== this.o.repoUrl) {
|
|
514
703
|
await this.runGit(['remote', 'set-url', 'origin', this.o.repoUrl]);
|
|
704
|
+
this.partialClone = null; // 换了远端 ⇒ promisor 判定失效(issue #122)
|
|
515
705
|
}
|
|
516
706
|
const push = await this.runGit(['remote', 'get-url', '--push', 'origin'], { allowNonZero: true });
|
|
517
707
|
const pushUrl = push.code === 0 ? push.stdout.trim() : '';
|
|
@@ -701,7 +891,7 @@ export class GitTransport {
|
|
|
701
891
|
}
|
|
702
892
|
// fetch 是网络操作(幂等读)→ 允许有限指数退避重试
|
|
703
893
|
await withSyncRetry(async () => {
|
|
704
|
-
await this.runGit(['fetch', 'origin'], { withCredential: true });
|
|
894
|
+
await this.runGit(['fetch', ...(await this.fetchOptionFlags()), 'origin'], { withCredential: true });
|
|
705
895
|
}, this.o.retry);
|
|
706
896
|
const remoteRef = `refs/remotes/origin/${branch}`;
|
|
707
897
|
const refRes = await this.runGit(['rev-parse', '--verify', '--quiet', remoteRef], { allowNonZero: true });
|
|
@@ -758,6 +948,12 @@ export class GitTransport {
|
|
|
758
948
|
const branch = await this.currentBranch();
|
|
759
949
|
if (branch === '')
|
|
760
950
|
return; // detached HEAD:没有 upstream 概念(与原「no tracking information」同姿态跳过)
|
|
951
|
+
// issue #122:稳态(远端没变)时**连 fetch 都不发**。一次 ls-remote 只回一行 refs(~1 KB),
|
|
952
|
+
// 而 fetch 即使没有新内容也要协商 refs、且远端仓库远大于同步内容时要遍历完整对象图
|
|
953
|
+
// (报告者现场:1.8 GB 仓库 / 6 MB 内容 ⇒ 一次「检查远端变更」卡死)。
|
|
954
|
+
// 跳过条件必须两个同时成立:远端 sha == 本地远端跟踪引用(没有新对象可拉)**且** HEAD == 该 sha
|
|
955
|
+
// (本地已合并)。只比 refs 就跳过 merge 会漏掉「上次 fetch 成功、merge 没跑完」的副本 ——
|
|
956
|
+
// 那种副本没有新对象可拉,但必须继续本地 merge。
|
|
761
957
|
// **不再用 `git pull --ff-only`**:它是「fetch 写 FETCH_HEAD + 读 FETCH_HEAD 合并」的两步动作,
|
|
762
958
|
// 并发驱动同一个工作副本时 FETCH_HEAD 会留下多行 for-merge 记录,随后每个 pull 都以
|
|
763
959
|
// `fatal: Cannot fast-forward to multiple branches.` 失败(本机 5 并发实测全败),
|
|
@@ -765,13 +961,21 @@ export class GitTransport {
|
|
|
765
961
|
// 显式 refspec fetch + 显式远端跟踪分支 merge 完全不读 FETCH_HEAD(同样 5 并发实测全成功),
|
|
766
962
|
// 语义与 ff-only pull 一致:能快进就快进,不能快进就报「历史分叉」。
|
|
767
963
|
// 参数与判定在 utils/git-fast-forward.ts(market 侧共用同一份实现)。
|
|
768
|
-
const
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
964
|
+
const remoteSha = await this.lsRemoteBranchSha(branch);
|
|
965
|
+
const localSha = await this.revParseSha(`refs/remotes/origin/${branch}`);
|
|
966
|
+
if (remoteSha !== null && remoteSha === localSha) {
|
|
967
|
+
if (await this.revParseSha('HEAD') === remoteSha)
|
|
968
|
+
return; // 已是最新:零 fetch、零 merge
|
|
969
|
+
}
|
|
970
|
+
else {
|
|
971
|
+
const fetched = await this.runGit(fastForwardFetchArgs(branch, await this.fetchOptionFlags()), { withCredential: true, allowNonZero: true });
|
|
972
|
+
if (fetched.code !== 0) {
|
|
973
|
+
const combined = fetched.stderr + ' ' + fetched.stdout;
|
|
974
|
+
// 远端还没有这个分支(全新空仓库 / 分支被改名)→ 与「无 upstream」一样静默跳过
|
|
975
|
+
if (isMissingRemoteRefError(combined))
|
|
976
|
+
return;
|
|
977
|
+
await this.throwPullFailure(combined, fetched.stderr);
|
|
978
|
+
}
|
|
775
979
|
}
|
|
776
980
|
const merged = await this.runGit(fastForwardMergeArgs(branch), { allowNonZero: true });
|
|
777
981
|
if (merged.code === 0)
|
|
@@ -812,6 +1016,8 @@ export class GitTransport {
|
|
|
812
1016
|
*/
|
|
813
1017
|
async runGit(args, opts = {}) {
|
|
814
1018
|
const cwd = opts.cwd === undefined ? this.o.workDir : opts.cwd;
|
|
1019
|
+
// 网络子命令一律带低速上限:真卡住在 30 s 内明确失败,而不是耗满 timeoutMs(issue #122)
|
|
1020
|
+
const networkArgs = NETWORK_SUBCOMMANDS.has(args[0] ?? '') ? [...LOW_SPEED_ARGS] : [];
|
|
815
1021
|
let extra = [];
|
|
816
1022
|
let token = null;
|
|
817
1023
|
let cleanup = null;
|
|
@@ -831,7 +1037,7 @@ export class GitTransport {
|
|
|
831
1037
|
// 而目录里原本就有别的内容时宁可放弃重试,也绝不删用户文件。
|
|
832
1038
|
const cloneTargetWasEmpty = args[0] === 'clone' ? await isEmptyDir(cwd) : false;
|
|
833
1039
|
try {
|
|
834
|
-
let result = await this.o.exec(this.o.gitBin, [...extra, ...args], { cwd, timeoutMs: this.o.timeoutMs });
|
|
1040
|
+
let result = await this.o.exec(this.o.gitBin, [...extra, ...networkArgs, ...args], { cwd, timeoutMs: this.o.timeoutMs });
|
|
835
1041
|
// issue #120:credential helper 必须经 shell 执行(值里不含空格也一样,git 统一拼成
|
|
836
1042
|
// `sh -c 'git <helper> ...'`),而 Android 上 git 二进制的 GIT_SHELL_PATH 指向 App 沙箱里
|
|
837
1043
|
// 不存在的 Termux sh;`core.askpass` 又要 exec 权限(Android 10+ 对 targetSdk ≥ 29 禁执行应用
|
|
@@ -842,33 +1048,11 @@ export class GitTransport {
|
|
|
842
1048
|
if (Object.keys(headerEnv).length > 0 && (args[0] !== 'clone' || cloneTargetWasEmpty)) {
|
|
843
1049
|
if (args[0] === 'clone')
|
|
844
1050
|
await clearDirContents(cwd);
|
|
845
|
-
result = await this.o.exec(this.o.gitBin, args, { cwd, timeoutMs: this.o.timeoutMs, env: headerEnv });
|
|
1051
|
+
result = await this.o.exec(this.o.gitBin, [...networkArgs, ...args], { cwd, timeoutMs: this.o.timeoutMs, env: headerEnv });
|
|
846
1052
|
}
|
|
847
1053
|
}
|
|
848
1054
|
if (result.code !== 0 && !opts.allowNonZero) {
|
|
849
|
-
|
|
850
|
-
// 统一分类(超时 / 网络 / 鉴权 / 5xx…):上层可据 kind / retryable 分流,不必解析 message
|
|
851
|
-
const classified = classifyNetworkErrorText(combined);
|
|
852
|
-
// issue #79:SSH 主机密钥校验失败 → 换成「怎么信任该主机」的可操作说明(绝不自动信任)
|
|
853
|
-
if (SSH_HOST_KEY_RE.test(combined)) {
|
|
854
|
-
throw new GitTransportError(this.hostKeyMessage(), { kind: 'auth', retryable: false });
|
|
855
|
-
}
|
|
856
|
-
// 推送 + 鉴权失败:git 原文只说「认证失败」——换成能照做的说明(令牌失效 / 无写权限 / 远端禁用了密码推送)
|
|
857
|
-
// issue #115:clone 的鉴权失败原文(remote: Write access to repository not granted / 403)读不出「缺哪种权限」,
|
|
858
|
-
// 换成点名 Contents: Read and write 的说明 —— 否则用户只会以为令牌坏了(细粒度 PAT 常只勾了 Administration)
|
|
859
|
-
// issue #120:helper 根本没能执行时,「请确认令牌有效」是错误指引(令牌可能是好的)——
|
|
860
|
-
// 这类失败必须点名平台原因,排在 push/clone 的鉴权文案之前。
|
|
861
|
-
const credentialBlocked = isCredentialExecFailure(combined);
|
|
862
|
-
const message = credentialBlocked
|
|
863
|
-
? this.msg('sync.git.credentialHelperBlocked', { err: this.mask(result.stderr, token) })
|
|
864
|
-
: opts.requirePushToken === true && classified.kind === 'auth'
|
|
865
|
-
? this.msg('sync.git.pushAuthFailed', { err: this.mask(result.stderr, token) })
|
|
866
|
-
: args[0] === 'clone' && classified.kind === 'auth'
|
|
867
|
-
? this.msg('sync.git.cloneAuthFailed', { err: this.mask(result.stderr, token) })
|
|
868
|
-
: this.msg('sync.git.cmdFailed', { args: args.join(' '), code: String(result.code), err: this.mask(result.stderr, token) });
|
|
869
|
-
// 归类同样不能交给原文:`cannot exec ...: No such file or directory` 里的 ENOENT 会被判成
|
|
870
|
-
// notfound(「仓库不存在」),而真实原因是凭据通道不可用 → 钉成 auth(非瞬时、别重试)。
|
|
871
|
-
throw new GitTransportError(message, credentialBlocked ? { kind: 'auth', retryable: false } : classified);
|
|
1055
|
+
this.raiseGitFailure(args, result, token, opts.requirePushToken === true);
|
|
872
1056
|
}
|
|
873
1057
|
return result;
|
|
874
1058
|
}
|
|
@@ -884,6 +1068,35 @@ export class GitTransport {
|
|
|
884
1068
|
await cleanup();
|
|
885
1069
|
}
|
|
886
1070
|
}
|
|
1071
|
+
/**
|
|
1072
|
+
* 把「git 退出非零」归一成可操作错误(runGit 与 clone 回落共用同一套文案与分类)。
|
|
1073
|
+
* token 只用于脱敏,绝不进 message。
|
|
1074
|
+
*/
|
|
1075
|
+
raiseGitFailure(args, result, token, requirePushToken) {
|
|
1076
|
+
const combined = result.stderr + ' ' + result.stdout;
|
|
1077
|
+
// 统一分类(超时 / 网络 / 鉴权 / 5xx…):上层可据 kind / retryable 分流,不必解析 message
|
|
1078
|
+
const classified = classifyNetworkErrorText(combined);
|
|
1079
|
+
// issue #79:SSH 主机密钥校验失败 → 换成「怎么信任该主机」的可操作说明(绝不自动信任)
|
|
1080
|
+
if (SSH_HOST_KEY_RE.test(combined)) {
|
|
1081
|
+
throw new GitTransportError(this.hostKeyMessage(), { kind: 'auth', retryable: false });
|
|
1082
|
+
}
|
|
1083
|
+
// 推送 + 鉴权失败:git 原文只说「认证失败」——换成能照做的说明(令牌失效 / 无写权限 / 远端禁用了密码推送)
|
|
1084
|
+
// issue #115:clone 的鉴权失败原文(remote: Write access to repository not granted / 403)读不出「缺哪种权限」,
|
|
1085
|
+
// 换成点名 Contents: Read and write 的说明 —— 否则用户只会以为令牌坏了(细粒度 PAT 常只勾了 Administration)
|
|
1086
|
+
// issue #120:helper 根本没能执行时,「请确认令牌有效」是错误指引(令牌可能是好的)——
|
|
1087
|
+
// 这类失败必须点名平台原因,排在 push/clone 的鉴权文案之前。
|
|
1088
|
+
const credentialBlocked = isCredentialExecFailure(combined);
|
|
1089
|
+
const message = credentialBlocked
|
|
1090
|
+
? this.msg('sync.git.credentialHelperBlocked', { err: this.mask(result.stderr, token) })
|
|
1091
|
+
: requirePushToken && classified.kind === 'auth'
|
|
1092
|
+
? this.msg('sync.git.pushAuthFailed', { err: this.mask(result.stderr, token) })
|
|
1093
|
+
: args[0] === 'clone' && classified.kind === 'auth'
|
|
1094
|
+
? this.msg('sync.git.cloneAuthFailed', { err: this.mask(result.stderr, token) })
|
|
1095
|
+
: this.msg('sync.git.cmdFailed', { args: args.join(' '), code: String(result.code), err: this.mask(result.stderr, token) });
|
|
1096
|
+
// 归类同样不能交给原文:`cannot exec ...: No such file or directory` 里的 ENOENT 会被判成
|
|
1097
|
+
// notfound(「仓库不存在」),而真实原因是凭据通道不可用 → 钉成 auth(非瞬时、别重试)。
|
|
1098
|
+
throw new GitTransportError(message, credentialBlocked ? { kind: 'auth', retryable: false } : classified);
|
|
1099
|
+
}
|
|
887
1100
|
/**
|
|
888
1101
|
* 为网络命令构造 credential helper 参数:
|
|
889
1102
|
* 仅 http(s) 远端需要 token;本地路径 / ssh 走 git 原生认证(密钥),不调用 provider。
|
|
@@ -8,8 +8,13 @@
|
|
|
8
8
|
* 显式 refspec fetch + 显式远端跟踪分支 merge 完全不读 FETCH_HEAD(同样 5 并发实测全成功),
|
|
9
9
|
* 语义与 ff-only pull 一致:能快进就快进,不能快进就报「历史分叉」。
|
|
10
10
|
*/
|
|
11
|
-
/**
|
|
12
|
-
|
|
11
|
+
/**
|
|
12
|
+
* `git fetch` 参数:只更新 origin/<branch> 这个远端跟踪引用,不写 FETCH_HEAD。
|
|
13
|
+
*
|
|
14
|
+
* `flags` 是给调用方补的拉取姿态(如 `--filter=blob:none`/`--no-tags`),必须排在 `origin` 之前:
|
|
15
|
+
* git 只认「子命令后、URL 前」的选项,放到 refspec 之后会被当成 refspec 解析。
|
|
16
|
+
*/
|
|
17
|
+
export declare function fastForwardFetchArgs(branch: string, flags?: readonly string[]): string[];
|
|
13
18
|
/** `git merge` 参数:快进到显式远端跟踪分支(不依赖 upstream 配置,也不读 FETCH_HEAD) */
|
|
14
19
|
export declare function fastForwardMergeArgs(branch: string): string[];
|
|
15
20
|
/** 远端还没有同名分支(全新空仓库 / 分支被改名):调用方按「无 upstream」同姿态静默跳过 */
|
|
@@ -8,9 +8,14 @@
|
|
|
8
8
|
* 显式 refspec fetch + 显式远端跟踪分支 merge 完全不读 FETCH_HEAD(同样 5 并发实测全成功),
|
|
9
9
|
* 语义与 ff-only pull 一致:能快进就快进,不能快进就报「历史分叉」。
|
|
10
10
|
*/
|
|
11
|
-
/**
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
/**
|
|
12
|
+
* `git fetch` 参数:只更新 origin/<branch> 这个远端跟踪引用,不写 FETCH_HEAD。
|
|
13
|
+
*
|
|
14
|
+
* `flags` 是给调用方补的拉取姿态(如 `--filter=blob:none`/`--no-tags`),必须排在 `origin` 之前:
|
|
15
|
+
* git 只认「子命令后、URL 前」的选项,放到 refspec 之后会被当成 refspec 解析。
|
|
16
|
+
*/
|
|
17
|
+
export function fastForwardFetchArgs(branch, flags = []) {
|
|
18
|
+
return ['fetch', ...flags, 'origin', `+refs/heads/${branch}:refs/remotes/origin/${branch}`];
|
|
14
19
|
}
|
|
15
20
|
/** `git merge` 参数:快进到显式远端跟踪分支(不依赖 upstream 配置,也不读 FETCH_HEAD) */
|
|
16
21
|
export function fastForwardMergeArgs(branch) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-config-manager",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Backup, restore, export, import, migrate and sync your DeepSeek Harness (DSH) configuration: settings, plugins, MCP servers, skills, agent presets and workspaces. One-click migration to another machine. DSH 配置一键备份、恢复、导出、导入、迁移与同步插件。",
|
|
6
6
|
"icon": "./icon.svg",
|
|
@@ -71,7 +71,7 @@ export const zh = {
|
|
|
71
71
|
'history.kind.profile-rename': '档案重命名',
|
|
72
72
|
'history.kind.profile-save': '档案保存',
|
|
73
73
|
'history.kind.profile-import': '档案导入',
|
|
74
|
-
'history.kind.sync-apply': '
|
|
74
|
+
'history.kind.sync-apply': '从远端合并',
|
|
75
75
|
'history.kind.autosync': '自动同步',
|
|
76
76
|
'history.kind.recovery': '恢复/回滚编排',
|
|
77
77
|
'history.kind.backup': '定时备份',
|
|
@@ -118,7 +118,7 @@ export const en = {
|
|
|
118
118
|
'history.kind.profile-rename': 'Profile rename',
|
|
119
119
|
'history.kind.profile-save': 'Profile save',
|
|
120
120
|
'history.kind.profile-import': 'Profile import',
|
|
121
|
-
'history.kind.sync-apply': '
|
|
121
|
+
'history.kind.sync-apply': 'Merge from remote',
|
|
122
122
|
'history.kind.autosync': 'Autosync',
|
|
123
123
|
'history.kind.recovery': 'Recovery/rollback',
|
|
124
124
|
'history.kind.backup': 'Scheduled backup',
|
package/src/client/locales.ts
CHANGED
|
@@ -123,7 +123,7 @@ export const zh = {
|
|
|
123
123
|
'overview.kind.other': '其他操作',
|
|
124
124
|
// 状态横幅
|
|
125
125
|
// —— 导出 ——
|
|
126
|
-
'export.hint': '
|
|
126
|
+
'export.hint': '默认导出推荐分区(可迁移、不含设备专属数据)。',
|
|
127
127
|
'export.encrypt': '加密备份',
|
|
128
128
|
'export.encryptHint': '用密码加密整个备份文件(AES-256-GCM)。密码仅用于本次加密,不会写入备份或任何配置文件;导入时必须输入该密码才能解锁。',
|
|
129
129
|
'export.password': '备份加密密码',
|
|
@@ -640,7 +640,7 @@ export const zh = {
|
|
|
640
640
|
'runs.kind.export': '导出',
|
|
641
641
|
'runs.kind.import': '导入',
|
|
642
642
|
'runs.kind.autosync': '自动同步',
|
|
643
|
-
'runs.kind.sync-apply': '
|
|
643
|
+
'runs.kind.sync-apply': '从远端合并',
|
|
644
644
|
'runs.kind.backup-schedule': '定时备份',
|
|
645
645
|
'runs.kind.restore': '快照恢复',
|
|
646
646
|
'runs.kind.profile-switch': '档案切换',
|
|
@@ -1013,7 +1013,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
1013
1013
|
'overview.kind.snapshot-delete': 'Snapshot delete',
|
|
1014
1014
|
'overview.kind.snapshot-prune': 'Snapshot prune',
|
|
1015
1015
|
'overview.kind.other': 'Other operation',
|
|
1016
|
-
'export.hint': 'Exports the recommended sections by default (portable, no device-specific data).
|
|
1016
|
+
'export.hint': 'Exports the recommended sections by default (portable, no device-specific data).',
|
|
1017
1017
|
'export.encrypt': 'Encrypt backup',
|
|
1018
1018
|
'export.encryptHint': 'Encrypt the entire backup file with a password (AES-256-GCM). The password is used once and never written into the backup or any config file; it is required to unlock the backup on import.',
|
|
1019
1019
|
'export.password': 'Backup encryption password',
|
|
@@ -1518,7 +1518,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
1518
1518
|
'runs.kind.export': 'Export',
|
|
1519
1519
|
'runs.kind.import': 'Import',
|
|
1520
1520
|
'runs.kind.autosync': 'Auto sync',
|
|
1521
|
-
'runs.kind.sync-apply': '
|
|
1521
|
+
'runs.kind.sync-apply': 'Merge from remote',
|
|
1522
1522
|
'runs.kind.backup-schedule': 'Scheduled backup',
|
|
1523
1523
|
'runs.kind.restore': 'Snapshot restore',
|
|
1524
1524
|
'runs.kind.profile-switch': 'Profile switch',
|
|
@@ -35,7 +35,6 @@ import type { MyConfigsApi, MyItemEntry } from './my-configs-api.ts'
|
|
|
35
35
|
import type { ListingStatusResponse } from '../../market/my-repo.ts'
|
|
36
36
|
import type { SyncApi, GithubPollResponse } from '../sync/sync-api.ts'
|
|
37
37
|
import { Button, Card, SectionTitle } from '../common/ui.tsx'
|
|
38
|
-
import { InfoHint } from '../common/InfoHint.tsx'
|
|
39
38
|
import { ConfirmDialog } from '../common/ConfirmDialog.tsx'
|
|
40
39
|
import { toast } from '../common/toast-store.ts'
|
|
41
40
|
import { redact } from '../../security/redaction.ts'
|
|
@@ -773,10 +772,11 @@ export function MyConfigsView({
|
|
|
773
772
|
<>
|
|
774
773
|
{/* 一键上传入口:点按钮 → 免责(首次)→ 弹窗向导 */}
|
|
775
774
|
<Card>
|
|
776
|
-
{/*
|
|
775
|
+
{/* 卡片里只有一个动作:按钮本身已写明作用对象(上传配置包),不再重复标题。
|
|
776
|
+
此处不再放 ⓘ:它的「先在导出页生成 zip / 含密钥的备份会被拒绝」已在点开后的
|
|
777
|
+
向导头(MyConfigsWizard 的 Modal.Header trailing)逐字出现,入口再放一遍是同一条信息两次。 */}
|
|
777
778
|
<div className={css.actionRow}>
|
|
778
779
|
<Button variant="primary" onClick={openUpload}>{t('myconfigs.upload.run')}</Button>
|
|
779
|
-
<InfoHint text={t('myconfigs.upload.selectHint')} label={t('common.infoHint')} />
|
|
780
780
|
</div>
|
|
781
781
|
</Card>
|
|
782
782
|
{renderList()}
|
|
@@ -145,11 +145,11 @@ export const zh = {
|
|
|
145
145
|
'myconfigs.upload.mode.migrate': '迁移(保留全部内容)',
|
|
146
146
|
'myconfigs.upload.mode.share': '分享(自动排除敏感内容)',
|
|
147
147
|
'myconfigs.upload.mode.shareHint': '分享模式将自动排除设备/平台相关分区(凭据状态 / 会话 / MCP / 工作区等),并对隐私内容强制拦截——发现任何敏感痕迹将拒绝发布,而不是仅警告。',
|
|
148
|
-
'myconfigs.upload.run': '
|
|
148
|
+
'myconfigs.upload.run': '上传配置包',
|
|
149
149
|
'myconfigs.update.title': '更新配置',
|
|
150
150
|
'myconfigs.update.hint': '版本自动 +1',
|
|
151
151
|
'myconfigs.update.run': '一键更新',
|
|
152
|
-
'myconfigs.update.zipHint': '
|
|
152
|
+
'myconfigs.update.zipHint': '选择新 zip 后自动校验,通过才能更新。',
|
|
153
153
|
'myconfigs.update.selectZip': '选择新 ZIP…',
|
|
154
154
|
// 上传/更新结果卡
|
|
155
155
|
'myconfigs.result.title': '上传/更新完成',
|
|
@@ -324,11 +324,11 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
324
324
|
'myconfigs.upload.mode.migrate': 'Migrate (keep everything)',
|
|
325
325
|
'myconfigs.upload.mode.share': 'Share (auto-exclude sensitive content)',
|
|
326
326
|
'myconfigs.upload.mode.shareHint': 'Share mode auto-excludes device/platform-specific sections (credentials status / sessions / MCP / workspaces, etc.) and enforces privacy scanning — any sensitive trace blocks publishing instead of just warning.',
|
|
327
|
-
'myconfigs.upload.run': 'Upload',
|
|
327
|
+
'myconfigs.upload.run': 'Upload bundle',
|
|
328
328
|
'myconfigs.update.title': 'Update config',
|
|
329
329
|
'myconfigs.update.hint': 'version +1 automatically',
|
|
330
330
|
'myconfigs.update.run': 'Update',
|
|
331
|
-
'myconfigs.update.zipHint': '
|
|
331
|
+
'myconfigs.update.zipHint': 'Pick a new zip and it will be validated automatically; only a passing zip can be installed.',
|
|
332
332
|
'myconfigs.update.selectZip': 'Choose new ZIP…',
|
|
333
333
|
'myconfigs.result.title': 'Upload/update complete',
|
|
334
334
|
'myconfigs.result.version': 'version {version}',
|
|
@@ -1260,8 +1260,8 @@ export function SyncPanel({ api, t, cmT }: SyncPanelProps) {
|
|
|
1260
1260
|
)}
|
|
1261
1261
|
{ready && (
|
|
1262
1262
|
<Card>
|
|
1263
|
-
{/* 动作行(方案 A 定案):用户只面对两个方向 ——
|
|
1264
|
-
|
|
1263
|
+
{/* 动作行(方案 A 定案):用户只面对两个方向 —— 下行「从远端合并到本机」/ 上行「推送到远端」。
|
|
1264
|
+
合并裁决并入下行按钮(有基线走三方裁决视图、无基线走老导入确认);并排两个按钮看不出该按哪个(issue 115)⇒ 按已确证远端事实只让一个落在 primary。 */}
|
|
1265
1265
|
<div className={css.actionRow}>
|
|
1266
1266
|
<Button
|
|
1267
1267
|
variant={pickSyncPrimary(state.statusInfo, channel) === 'sync' ? 'primary' : 'ghost'}
|