dsh-config-manager 0.2.3 → 0.3.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.
Files changed (132) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/lib/adapters/self.d.ts +44 -4
  4. package/lib/adapters/self.js +63 -3
  5. package/lib/client.d.ts +11 -6
  6. package/lib/client.js +1256 -1185
  7. package/lib/core/analyzer.d.ts +3 -0
  8. package/lib/core/analyzer.js +32 -56
  9. package/lib/core/backup.d.ts +36 -0
  10. package/lib/core/backup.js +125 -3
  11. package/lib/core/import-defaults.d.ts +13 -0
  12. package/lib/core/import-defaults.js +14 -0
  13. package/lib/core/importer.d.ts +10 -1
  14. package/lib/core/importer.js +7 -1
  15. package/lib/core/messages.d.ts +4 -0
  16. package/lib/core/messages.js +8 -0
  17. package/lib/core/model-tools.js +19 -3
  18. package/lib/index.d.ts +1 -1
  19. package/lib/index.js +12 -22
  20. package/lib/routes/import.js +3 -1
  21. package/lib/routes/recovery.js +226 -2
  22. package/lib/routes/sync.js +26 -7
  23. package/lib/security/integrity.d.ts +24 -0
  24. package/lib/security/integrity.js +30 -0
  25. package/lib/security/unverified-file-blocks.d.ts +73 -0
  26. package/lib/security/unverified-file-blocks.js +151 -0
  27. package/lib/sync/autosync-config.d.ts +8 -4
  28. package/lib/sync/autosync-config.js +2 -2
  29. package/lib/sync/autosync-scheduler.d.ts +61 -3
  30. package/lib/sync/autosync-scheduler.js +87 -10
  31. package/lib/sync/config-issue-reporting.d.ts +32 -0
  32. package/lib/sync/config-issue-reporting.js +63 -0
  33. package/lib/sync/gc-skip.d.ts +42 -0
  34. package/lib/sync/gc-skip.js +57 -0
  35. package/lib/sync/git/git-transport.d.ts +56 -2
  36. package/lib/sync/git/git-transport.js +344 -73
  37. package/lib/sync/merge-preview-text.d.ts +11 -0
  38. package/lib/sync/merge-preview-text.js +38 -0
  39. package/lib/sync/s3/s3-transport.d.ts +8 -1
  40. package/lib/sync/s3/s3-transport.js +19 -5
  41. package/lib/sync/session-tombstones.d.ts +53 -0
  42. package/lib/sync/session-tombstones.js +52 -0
  43. package/lib/sync/sync-config.d.ts +55 -5
  44. package/lib/sync/sync-config.js +188 -15
  45. package/lib/sync/sync-engine.d.ts +21 -7
  46. package/lib/sync/sync-engine.js +63 -85
  47. package/lib/sync/webdav/webdav-transport.d.ts +11 -1
  48. package/lib/sync/webdav/webdav-transport.js +22 -6
  49. package/lib/ui/i18n.js +56 -36
  50. package/lib/ui/import-wizard.d.ts +1 -1
  51. package/lib/ui/import-wizard.js +2 -1
  52. package/lib/ui/overview-view.d.ts +6 -2
  53. package/lib/ui/overview-view.js +8 -1
  54. package/lib/ui/report.js +2 -1
  55. package/lib/utils/git-fast-forward.d.ts +7 -2
  56. package/lib/utils/git-fast-forward.js +8 -3
  57. package/package.json +2 -2
  58. package/src/adapters/self.test.ts +126 -85
  59. package/src/adapters/self.ts +73 -6
  60. package/src/client/ConfigManagerSection.tsx +0 -3
  61. package/src/client/common/Icon.tsx +3 -0
  62. package/src/client/common/SectionComposition.tsx +6 -1
  63. package/src/client/common/info-hint-guard.test.ts +7 -5
  64. package/src/client/config-manager.module.css +254 -13
  65. package/src/client/environment/EnvironmentPanel.tsx +57 -47
  66. package/src/client/environment/maintenance/DiskUsageCard.tsx +3 -1
  67. package/src/client/export/ExportView.tsx +3 -1
  68. package/src/client/home/HomePanel.tsx +25 -5
  69. package/src/client/import/ImportWizardView.tsx +1 -3
  70. package/src/client/import/import-wizard-steps.tsx +1 -17
  71. package/src/client/library/ArtifactRow.tsx +13 -2
  72. package/src/client/library/LibraryPanel.tsx +12 -2
  73. package/src/client/locales.ts +53 -34
  74. package/src/client/lucide-icons.d.ts +2 -0
  75. package/src/client/market/MarketPanel.tsx +12 -11
  76. package/src/client/market/market-locales.ts +16 -16
  77. package/src/client/recovery/RollbackCard.tsx +4 -2
  78. package/src/client/recovery/recovery-locales.test.ts +4 -2
  79. package/src/client/recovery/recovery-locales.ts +23 -23
  80. package/src/client/sync/AutosyncCard.tsx +36 -10
  81. package/src/client/sync/SyncChannelEntryCard.tsx +18 -19
  82. package/src/client/sync/SyncPanel.tsx +32 -33
  83. package/src/client/sync/SyncProbeRow.tsx +23 -6
  84. package/src/client/sync/SyncSectionPickerDialog.tsx +2 -2
  85. package/src/client/sync/sync-locales.ts +24 -24
  86. package/src/core/analyzer.ts +37 -56
  87. package/src/core/backup.test.ts +139 -1
  88. package/src/core/backup.ts +145 -4
  89. package/src/core/import-defaults.ts +13 -0
  90. package/src/core/importer.ts +12 -2
  91. package/src/core/integrity-extra-entries.test.ts +205 -0
  92. package/src/core/messages.test.ts +3 -1
  93. package/src/core/messages.ts +8 -0
  94. package/src/core/model-tools.test.ts +33 -0
  95. package/src/core/model-tools.ts +20 -4
  96. package/src/index.ts +12 -21
  97. package/src/routes/import.ts +4 -1
  98. package/src/routes/recovery.test.ts +198 -1
  99. package/src/routes/recovery.ts +194 -1
  100. package/src/routes/sync.ts +27 -8
  101. package/src/security/integrity.ts +37 -0
  102. package/src/security/unverified-file-blocks.ts +192 -0
  103. package/src/sync/autosync-config.ts +9 -5
  104. package/src/sync/autosync-scheduler.test.ts +133 -6
  105. package/src/sync/autosync-scheduler.ts +118 -14
  106. package/src/sync/config-issue-reporting.test.ts +148 -0
  107. package/src/sync/config-issue-reporting.ts +75 -0
  108. package/src/sync/gc-skip.test.ts +66 -0
  109. package/src/sync/gc-skip.ts +64 -0
  110. package/src/sync/git/git-probe.test.ts +6 -1
  111. package/src/sync/git/git-transport.test.ts +351 -14
  112. package/src/sync/git/git-transport.ts +343 -79
  113. package/src/sync/merge-preview-text.ts +37 -0
  114. package/src/sync/s3/s3-delete-index-404.test.ts +26 -1
  115. package/src/sync/s3/s3-transport.ts +23 -4
  116. package/src/sync/session-tombstones.ts +90 -0
  117. package/src/sync/sync-config.test.ts +170 -1
  118. package/src/sync/sync-config.ts +184 -14
  119. package/src/sync/sync-engine.test.ts +238 -0
  120. package/src/sync/sync-engine.ts +79 -83
  121. package/src/sync/webdav/webdav-delete-index-404.test.ts +29 -1
  122. package/src/sync/webdav/webdav-transport.ts +26 -5
  123. package/src/ui/disk-usage-view.test.ts +2 -1
  124. package/src/ui/i18n.test.ts +47 -0
  125. package/src/ui/i18n.ts +58 -38
  126. package/src/ui/import-wizard.test.ts +18 -1
  127. package/src/ui/import-wizard.ts +3 -2
  128. package/src/ui/overview-view.test.ts +9 -0
  129. package/src/ui/overview-view.ts +9 -2
  130. package/src/ui/report.test.ts +4 -3
  131. package/src/ui/report.ts +2 -1
  132. 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 execFile(promise 封装,数组参数无 shell 注入),
11
- * 始终使用系统 PATH 中的 git(固定命令 'git'),可注入 exec 便于测试。
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,11 @@
17
18
  * - 契约:同 id 重复 upload = 覆盖(幂等友好:内容无变化时不产生 commit);download 不存在抛错;
18
19
  * delete 不存在视为成功。
19
20
  */
20
- import { execFile } from 'node:child_process';
21
+ import { spawn } from 'node:child_process';
22
+ import type { ChildProcess } from 'node:child_process';
21
23
  import fs from 'node:fs/promises';
22
24
  import os from 'node:os';
23
25
  import path from 'node:path';
24
- import { promisify } from 'node:util';
25
26
 
26
27
  import { createSnapshotFs, joinFs } from '../fs.ts';
27
28
  import type { SnapshotFs } from '../fs.ts';
@@ -30,6 +31,7 @@ import type { SnapshotDirManifest } from '../layout.ts';
30
31
  import { BLOB_SECTIONS, gcBlobs, referencedBlobHashes } from '../blob-store.ts';
31
32
  import type { BlobSink } from '../blob-store.ts';
32
33
  import { deserializeSnapshot, serializeSnapshot } from '../snapshot-json.ts';
34
+ import { classifyRepoUrl, isPrivateRepoHost } from '../sync-config.ts';
33
35
  import {
34
36
  classifyNetworkErrorText, computeSnapshotMeta, DEFAULT_SYNC_TIMEOUT_MS, isEncryptedSections,
35
37
  SyncTransportError, withSyncRetry,
@@ -48,8 +50,6 @@ import type { MsgFunc } from '../../core/messages.ts';
48
50
  import { withWorkdirLock, WorkdirLockError } from '../../utils/workdir-lock.ts';
49
51
  import { fastForwardFetchArgs, fastForwardMergeArgs, isMissingRemoteRefError, parseCurrentBranch } from '../../utils/git-fast-forward.ts';
50
52
 
51
- const execFileAsync = promisify(execFile);
52
-
53
53
  /** git 命令执行结果(code ≠ 0 = git 命令失败/退出非零) */
54
54
  export interface GitExecResult {
55
55
  stdout: string;
@@ -57,7 +57,7 @@ export interface GitExecResult {
57
57
  code: number;
58
58
  }
59
59
 
60
- /** 可注入的 git 执行器(测试 mock 用);默认实现 = child_process.execFile promise 封装 */
60
+ /** 可注入的 git 执行器(测试 mock 用);默认实现 = child_process.spawn promise 封装(超时杀进程树) */
61
61
  export type GitExecFn = (
62
62
  cmd: string,
63
63
  args: string[],
@@ -146,7 +146,7 @@ export interface GitTransportOptions {
146
146
  timeoutMs?: number;
147
147
  /** 幂等读操作(list/download 的 clone/pull)的网络重试参数;缺省 attempts=3、250ms 起指数退避 */
148
148
  retry?: SyncRetryOptions;
149
- /** 注入 exec(测试 mock 用);缺省 = execFile 封装 */
149
+ /** 注入 exec(测试 mock 用);缺省 = spawn 封装(超时杀进程树) */
150
150
  exec?: GitExecFn;
151
151
  /** 提交作者(写入远端历史),默认 DSH Config Sync <sync@dsh.local> */
152
152
  author?: GitAuthor;
@@ -184,10 +184,42 @@ const BLOBS_REL = 'blobs';
184
184
  * 远端分支的顶层出现别的名字 = 这不是本插件的同步仓库,合并它等于把别人的仓库内容搬进工作副本。
185
185
  */
186
186
  const ADOPT_ROOT_ALLOW = new Set<string>([SNAPSHOTS_REL, SNAPSHOTS_ENCRYPTED_REL, BLOBS_REL]);
187
+ /**
188
+ * 同步仓库只在这三个顶层目录里写内容 ⇒ partial clone 的 sparse-checkout 面与它同源(issue #122)。
189
+ * 远端仓库比同步内容大几个数量级时(报告者现场:1.8 GB 仓库 / 6 MB 内容),全量 clone 与全量 fetch
190
+ * 都要拉完整对象图,而工作副本真正需要的只有这三个目录。
191
+ */
192
+ const SYNC_ROOT_DIRS: readonly string[] = [...ADOPT_ROOT_ALLOW];
193
+ /**
194
+ * 走网络的 git 子命令:一律附加低速上限(issue #122)。
195
+ *
196
+ * 为什么必须显式设:git 的默认 http 低速门限极宽松,链路被限速/黑洞时一条 fetch 能安静地耗到
197
+ * timeoutMs(120 s),再乘上重试次数 = 用户看到的就是「永久停在正在检查远端变更」。
198
+ * 30 s 内平均速率低于 1 KB/s 即明确失败,交给既有分类器判 timeout(瞬时、可重试)。
199
+ */
200
+ const NETWORK_SUBCOMMANDS = new Set<string>(['clone', 'fetch', 'push', 'ls-remote', 'pull']);
201
+ const LOW_SPEED_ARGS: readonly string[] = ['-c', 'http.lowSpeedLimit=1000', '-c', 'http.lowSpeedTime=30'];
202
+ /**
203
+ * 协议护栏(SEC-12):**所有** git 子进程恒带,与「这条命令走不走网络」无关。
204
+ *
205
+ * 为什么恒带而不是只给网络命令:危险的不只是远端协议本身 —— `ext::sh -c ...` / `file://`
206
+ * 这类地址在 clone/fetch 之外还能经 `remote set-url`、配置重载等路径被触发,而本插件的 repoUrl
207
+ * 来自用户可编辑的 sync-config.json 与可导入的配置包。把 `ext` 与 `file` 传输整体关掉,
208
+ * 就等于「repoUrl 无论被改成什么,都只能走 http/https/ssh 三种真协议」。
209
+ */
210
+ const PROTOCOL_GUARD_ARGS: readonly string[] = ['-c', 'protocol.ext.allow=never'];
211
+ /** 网络仓库额外禁 file 传输(本地目录仓库是显式支持的用法,不能一刀切禁掉)。 */
212
+ const FILE_PROTOCOL_GUARD_ARGS: readonly string[] = ['-c', 'protocol.file.allow=never'];
213
+ /** 报错/日志里回显的地址占位符:不带真实 repoUrl(脱敏由上层的 mask() 负责)。 */
214
+ const REPO_URL_PLACEHOLDER = '[REPO_URL]';
187
215
  const DEFAULT_AUTHOR: GitAuthor = { name: 'DSH Config Sync', email: 'sync@dsh.local' };
188
216
  const DEFAULT_CREDENTIAL_USERNAME = 'oauth2';
189
217
  /** 快照 id 安全字符集:字母数字开头,仅 . _ -;防路径穿越与 commit message 注入 */
190
218
  const SAFE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
219
+ /** git 对象 id(sha1 40 位 / sha256 64 位)—— ls-remote 输出与 rev-parse 结果共用同一判定 */
220
+ const SHA_RE = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/;
221
+ /** 老 git 不认新选项(partial clone 需 ≥2.19、cone sparse-checkout 需 ≥2.25):据此回落全量 clone */
222
+ const UNSUPPORTED_GIT_OPTION_RE = /unknown option|unrecognized argument|unknown switch|is not a git command|unsupported/i;
191
223
 
192
224
  /**
193
225
  * SSH 主机密钥校验失败(issue #79):git 只回一句 `Host key verification failed`,
@@ -201,35 +233,118 @@ const SSH_HOST_KEY_RE = /host key verification failed|remote host identification
201
233
  */
202
234
  const DIVERGED_HISTORY_RE = /refusing to merge unrelated histories|unrelated histories|not possible to fast-forward/i;
203
235
 
204
- const defaultExec: GitExecFn = async (cmd, args, opts) => {
205
- try {
206
- const { stdout, stderr } = await execFileAsync(cmd, args, {
207
- cwd: opts.cwd,
208
- timeout: opts.timeoutMs,
209
- maxBuffer: 16 * 1024 * 1024,
210
- windowsHide: true,
211
- encoding: 'utf8',
212
- // issue #120:credential helper 走 shell 且本机没有可用 shell 时,改用 env 注入认证头重试 ——
213
- // 追加在继承的 process.env 上(env 缺省 = 完全继承,不改变既有行为)
214
- env: opts.env === undefined ? undefined : { ...process.env, ...opts.env },
215
- });
216
- return { stdout: String(stdout), stderr: String(stderr), code: 0 };
217
- } catch (err) {
218
- const e = err as { code?: number | string; killed?: boolean; signal?: string; stdout?: string; stderr?: string };
219
- const detail = String(e.stderr ?? '') !== '' ? String(e.stderr) : (err instanceof Error ? err.message : String(err));
220
- // 超时(execFile 到点 kill)在 git 的 stderr 里常常是空的:补一句可判定的文字,
221
- // 让 transport.ts 的统一分类器把它识别成 timeout(瞬时、可重试),而不是 unknown。
222
- const timedOut = e.killed === true || e.signal === 'SIGTERM' || e.code === 'ETIMEDOUT';
223
- const stderr = timedOut && !/timed out|timeout/i.test(detail)
224
- ? `git command timed out after ${String(opts.timeoutMs ?? 0)}ms: ${detail}`
225
- : detail;
226
- return {
227
- stdout: String(e.stdout ?? ''),
228
- stderr,
229
- code: typeof e.code === 'number' ? e.code : 1,
236
+ /** 超时 kill 之后等 stdio 关闭的宽限:逃出进程组的孙进程可能一直握着管道(issue #122) */
237
+ const EXEC_KILL_GRACE_MS = 5_000;
238
+ /** 单条命令累计输出上限(与旧 execFile maxBuffer 同口径,超限即 kill) */
239
+ const EXEC_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
240
+
241
+ /**
242
+ * 默认 git 执行器:spawn + **进程树** kill(issue #122)。
243
+ *
244
+ * 为什么不再用 execFile:它的 timeout 只对 git.exe 自己发 SIGTERM,而真正在下包的是 git.exe 的子进程
245
+ * (git-remote-https / git index-pack)。父进程被杀、孙进程继续写 tmp_pack_*,管道不关闭 ⇒ execFile 的
246
+ * promise 连超时错误都可能不返回 ⇒ 上层「正在检查远端变更...」永久挂起,下一轮重试再起一份,最终 N 份
247
+ * 并发下载同一个 pack。
248
+ * 这里改为:POSIX 上 detached 起独立进程组,超时对**整组**发 SIGKILL;Windows 上 `taskkill /T /F`;
249
+ * 无论 kill 是否成功,宽限期到点都带着已收集输出收尾 —— 绝不把「管道没关」变成上层无限等待。
250
+ *
251
+ * 导出仅供测试直接验证「超时 + 进程树 kill + 有界收尾」(issue #122 的验收面)。
252
+ */
253
+ export const defaultGitExec: GitExecFn = async (cmd, args, opts) =>
254
+ await new Promise<GitExecResult>((resolve) => {
255
+ const isWin = process.platform === 'win32';
256
+ // issue #120:credential helper 走 shell 且本机没有可用 shell 时,改用 env 注入认证头重试 ——
257
+ // 追加在继承的 process.env 上(env 缺省 = 完全继承,不改变既有行为)
258
+ const env = opts.env === undefined ? undefined : { ...process.env, ...opts.env };
259
+ let stdout = '';
260
+ let stderr = '';
261
+ let bytes = 0;
262
+ let settled = false;
263
+ let timedOut = false;
264
+ let overflow = false;
265
+ let timer: NodeJS.Timeout | null = null;
266
+ let graceTimer: NodeJS.Timeout | null = null;
267
+ let child: ChildProcess | null = null;
268
+
269
+ const settle = (code: number): void => {
270
+ if (settled) return;
271
+ settled = true;
272
+ if (timer !== null) clearTimeout(timer);
273
+ if (graceTimer !== null) clearTimeout(graceTimer);
274
+ let text = stderr;
275
+ if (code !== 0) {
276
+ if (overflow && !/exceed/i.test(text)) text = `${text}\noutput exceeded ${String(EXEC_MAX_OUTPUT_BYTES)} bytes`;
277
+ if (text.trim() === '') text = `git ${args[0] ?? ''} exited with code ${String(code)}`;
278
+ // 超时在 git 的 stderr 里常常是空的:补一句可判定的文字,让 transport.ts 的统一分类器
279
+ // 把它识别成 timeout(瞬时、可重试),而不是 unknown。
280
+ if (timedOut && !/timed out|timeout/i.test(text)) {
281
+ text = `git command timed out after ${String(opts.timeoutMs ?? 0)}ms: ${text}`;
282
+ }
283
+ }
284
+ resolve({ stdout, stderr: text, code });
230
285
  };
231
- }
232
- };
286
+
287
+ const killTree = (): void => {
288
+ const pid = child?.pid;
289
+ if (pid === undefined) return;
290
+ if (isWin) {
291
+ // 只 SIGTERM git.exe 会留下 git-remote-https / index-pack 孤儿(issue #122)⇒ 整棵树强杀
292
+ try {
293
+ spawn('taskkill', ['/pid', String(pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' })
294
+ .on('error', () => { /* 尽力而为:kill 失败也要靠宽限期收尾 */ });
295
+ } catch { /* 同上 */ }
296
+ return;
297
+ }
298
+ try {
299
+ process.kill(-pid, 'SIGKILL'); // 负 pid = 整个进程组(detached 起的子进程是组长)
300
+ } catch {
301
+ try { child?.kill('SIGKILL'); } catch { /* 已退出 */ }
302
+ }
303
+ };
304
+
305
+ try {
306
+ child = spawn(cmd, args, {
307
+ ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }),
308
+ ...(env === undefined ? {} : { env }),
309
+ windowsHide: true,
310
+ // POSIX:独立进程组,超时才可能连孙进程一起杀;Windows 用 taskkill /T
311
+ detached: !isWin,
312
+ stdio: ['ignore', 'pipe', 'pipe'],
313
+ });
314
+ } catch (err) {
315
+ resolve({ stdout: '', stderr: err instanceof Error ? err.message : String(err), code: 1 });
316
+ return;
317
+ }
318
+
319
+ const collect = (sink: 'out' | 'err') => (chunk: Buffer): void => {
320
+ bytes += chunk.length;
321
+ if (bytes > EXEC_MAX_OUTPUT_BYTES) {
322
+ if (!overflow) {
323
+ overflow = true;
324
+ killTree();
325
+ }
326
+ return;
327
+ }
328
+ if (sink === 'out') stdout += chunk.toString('utf8');
329
+ else stderr += chunk.toString('utf8');
330
+ };
331
+ child.stdout?.on('data', collect('out'));
332
+ child.stderr?.on('data', collect('err'));
333
+ child.on('error', (err) => {
334
+ stderr = stderr === '' ? err.message : stderr;
335
+ settle(1);
336
+ });
337
+ child.on('close', (code) => { settle(typeof code === 'number' ? code : 1); });
338
+
339
+ if (opts.timeoutMs !== undefined) {
340
+ timer = setTimeout(() => {
341
+ timedOut = true;
342
+ killTree();
343
+ // 进程树 kill 之后管道仍可能被握着(逃出进程组的孙进程)⇒ 宽限期到点强制收尾
344
+ graceTimer = setTimeout(() => { settle(1); }, EXEC_KILL_GRACE_MS);
345
+ }, Math.max(1, opts.timeoutMs));
346
+ }
347
+ });
233
348
 
234
349
  /** 实现 SyncTransport 的 git 通道。所有操作前 ensureRepo() 保证工作副本就绪,网络命令带凭据。 */
235
350
  export class GitTransport implements SyncTransport {
@@ -244,6 +359,8 @@ export class GitTransport implements SyncTransport {
244
359
  exec: GitExecFn;
245
360
  };
246
361
  private repoReady = false;
362
+ /** 该工作副本是否 partial clone(remote.origin.promisor);null = 尚未判定(issue #122) */
363
+ private partialClone: boolean | null = null;
247
364
  /**
248
365
  * 工作副本锁的可重入深度:list/upload/download/delete 都会经 ensureRepo → reconcileOrigin,
249
366
  * rebuildWorkDir → adoptRemoteHistory 也各自嵌套;外层入口持锁,内层直接跑。
@@ -258,6 +375,8 @@ export class GitTransport implements SyncTransport {
258
375
  */
259
376
  private readErrors: { file: string; reason: string }[] = [];
260
377
  private readonly msg: MsgFunc;
378
+ /** repoUrl 是否为网络地址(本地目录仓库 == false):决定要不要额外禁 file 传输(SEC-12)。 */
379
+ private readonly networkRepo: boolean;
261
380
 
262
381
  constructor(options: GitTransportOptions) {
263
382
  if (typeof options.repoUrl !== 'string' || options.repoUrl.length === 0) {
@@ -271,6 +390,16 @@ export class GitTransport implements SyncTransport {
271
390
  throw new GitTransportError(zhMsg('sync.git.credentialsRequired'));
272
391
  }
273
392
  this.msg = options.msg ?? zhMsg;
393
+ // SEC-12:repoUrl 来自用户可编辑配置与可导入配置包 —— 构造期就收成「只能是真网络地址或本地目录」,
394
+ // 环回/私网/链路本地一律拒(把家目录配置推到 localhost 上的抓取器是最省事的外泄方式)。
395
+ // **已知行为变更**:局域网 NAS / 内网 git 服务器作为同步目标此后会被拒绝(见 sync-config.ts
396
+ // 的 isPrivateRepoHost 注释里的取舍说明),这是有意的安全收紧,不是回归。
397
+ // 地址本体不回显进错误(占位符代替),脱敏仍由 mask() 兜底。
398
+ const repoShape = classifyRepoUrl(options.repoUrl);
399
+ if (repoShape === null || (repoShape.kind !== 'local' && isPrivateRepoHost(repoShape.host))) {
400
+ throw new GitTransportError(this.msg('sync.git.repoUrlInvalid', { url: REPO_URL_PLACEHOLDER }));
401
+ }
402
+ this.networkRepo = repoShape.kind !== 'local';
274
403
  this.o = {
275
404
  repoUrl: options.repoUrl,
276
405
  workDir: options.workDir,
@@ -278,7 +407,7 @@ export class GitTransport implements SyncTransport {
278
407
  gitBin: 'git',
279
408
  timeoutMs: options.timeoutMs ?? DEFAULT_SYNC_TIMEOUT_MS,
280
409
  retry: options.retry ?? {},
281
- exec: options.exec ?? defaultExec,
410
+ exec: options.exec ?? defaultGitExec,
282
411
  author: options.author ?? DEFAULT_AUTHOR,
283
412
  credentialUsername: options.credentialUsername ?? DEFAULT_CREDENTIAL_USERNAME,
284
413
  };
@@ -480,12 +609,12 @@ export class GitTransport implements SyncTransport {
480
609
  */
481
610
  async checkIsPrivate(): Promise<boolean> {
482
611
  if (this.privateHint !== null) return this.privateHint;
483
- const anon = await this.runGit(['ls-remote', this.o.repoUrl], { allowNonZero: true, cwd: undefined });
612
+ const anon = await this.runGit(['ls-remote', '--', this.o.repoUrl], { allowNonZero: true, cwd: undefined });
484
613
  if (anon.code === 0) {
485
614
  this.privateHint = false;
486
615
  return false;
487
616
  }
488
- const authed = await this.runGit(['ls-remote', this.o.repoUrl], { allowNonZero: true, cwd: undefined, withCredential: true });
617
+ const authed = await this.runGit(['ls-remote', '--', this.o.repoUrl], { allowNonZero: true, cwd: undefined, withCredential: true });
489
618
  if (authed.code === 0) {
490
619
  this.privateHint = true;
491
620
  return true;
@@ -584,7 +713,7 @@ export class GitTransport implements SyncTransport {
584
713
  // 每次尝试前重查 .git:上一次尝试其实建出了仓库就视为成功,避免「目录非空」误判。
585
714
  await withSyncRetry(async () => {
586
715
  if (await fsx.exists(gitDir)) return;
587
- await this.runGit(['clone', this.o.repoUrl, '.'], { cwd: this.o.workDir, withCredential: true });
716
+ await this.cloneIntoWorkDir();
588
717
  }, this.o.retry);
589
718
  // 仓库级提交身份(写入远端历史,可经 author 选项覆盖;不改全局配置)
590
719
  await this.runGit(['config', 'user.name', this.o.author.name]);
@@ -592,6 +721,88 @@ export class GitTransport implements SyncTransport {
592
721
  this.repoReady = true;
593
722
  }
594
723
 
724
+ /**
725
+ * 首次 clone(issue #122):partial clone + sparse-checkout,只落地同步内容。
726
+ *
727
+ * 为什么:同步仓库可能比同步内容大几个数量级(报告者现场 1.8 GB 仓库 / 6 MB 内容)。此前 `git clone`
728
+ * 全量落地,之后每次 fetch 还要拉完整对象图 ⇒ 一次「检查远端变更」就下几百 MB。
729
+ * `--filter=blob:none` 只取 commit+tree,`--no-checkout` 先不落地任何文件,
730
+ * `sparse-checkout set --cone <三个目录>` 之后 checkout 只 materialize 这三个目录的 blob。
731
+ *
732
+ * 老 git 不认这些选项(unknown option)时回落全量 clone —— 行为与修复前完全一致,绝不因优化失败。
733
+ */
734
+ private async cloneIntoWorkDir(): Promise<void> {
735
+ const cwd = this.o.workDir;
736
+ this.partialClone = null; // reclone 后重新判定(配置可能变)
737
+ const filteredArgs = ['clone', '--filter=blob:none', '--no-checkout', '--', this.o.repoUrl, '.'];
738
+ const filtered = await this.runGit(filteredArgs, { cwd, withCredential: true, allowNonZero: true });
739
+ if (filtered.code !== 0) {
740
+ if (!UNSUPPORTED_GIT_OPTION_RE.test(filtered.stderr + ' ' + filtered.stdout)) {
741
+ // 真实失败(网络 / 鉴权 / 超时):沿用既有分类与文案(含 issue #120 的凭据通道判定)
742
+ this.raiseGitFailure(filteredArgs, filtered, await this.readTokenOnce(), false);
743
+ }
744
+ // 老 git:回落全量 clone(全量 clone 自带 checkout)
745
+ await clearDirContents(cwd);
746
+ await this.runGit(['clone', '--', this.o.repoUrl, '.'], { cwd, withCredential: true });
747
+ return;
748
+ }
749
+ const branch = await this.currentBranch();
750
+ const head = await this.runGit(['rev-parse', '--verify', '--quiet', 'HEAD'], { allowNonZero: true });
751
+ if (head.code !== 0) return; // 空仓库:没有可 checkout 的树,后续 push 会建分支
752
+ const target = branch === '' ? 'HEAD' : branch;
753
+ const sparse = await this.runGit(['sparse-checkout', 'set', '--cone', ...SYNC_ROOT_DIRS], { allowNonZero: true });
754
+ if (sparse.code !== 0) {
755
+ // 老 git 没有 cone 模式:宁可全量落地(修复前行为),也不留下一个空工作树
756
+ await this.runGit(['checkout', target], { allowNonZero: true });
757
+ return;
758
+ }
759
+ // checkout 失败必须可见(空工作树会让上层以为「远端没有快照」)
760
+ await this.runGit(['checkout', target]);
761
+ }
762
+
763
+ /**
764
+ * 该工作副本是不是 partial clone(`remote.origin.promisor=true`)。
765
+ *
766
+ * 只有 partial clone 才能给 fetch 加 `--filter=blob:none`:对普通全量副本加过滤会让后续 checkout
767
+ * 拿到缺失的 blob。存量副本(1.8 GB 那种)因此只受益于 ls-remote 短路与 `--no-tags`,不会被改坏。
768
+ */
769
+ private async isPartialClone(): Promise<boolean> {
770
+ if (this.partialClone !== null) return this.partialClone;
771
+ const res = await this.runGit(['config', '--get', 'remote.origin.promisor'], { allowNonZero: true });
772
+ this.partialClone = res.code === 0 && res.stdout.trim() === 'true';
773
+ return this.partialClone;
774
+ }
775
+
776
+ /** 拉取姿态:partial clone 保持 blob 过滤;同步仓库不需要 tag(issue #122) */
777
+ private async fetchOptionFlags(): Promise<string[]> {
778
+ const flags: string[] = [];
779
+ if (await this.isPartialClone()) flags.push('--filter=blob:none');
780
+ flags.push('--no-tags');
781
+ return flags;
782
+ }
783
+
784
+ /** 远端分支当前的 sha(ls-remote 只取一行 refs);读不到 → null(短路是优化,失败一律回落正常 fetch) */
785
+ private async lsRemoteBranchSha(branch: string): Promise<string | null> {
786
+ const res = await this.runGit(
787
+ ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`],
788
+ { withCredential: true, allowNonZero: true },
789
+ );
790
+ if (res.code !== 0) return null;
791
+ for (const line of res.stdout.split('\n')) {
792
+ const [sha, ref] = line.trim().split('\t');
793
+ if (ref === `refs/heads/${branch}` && sha !== undefined && SHA_RE.test(sha)) return sha;
794
+ }
795
+ return null;
796
+ }
797
+
798
+ /** 解析引用指向的 sha;读不到 / 不是 sha → null */
799
+ private async revParseSha(ref: string): Promise<string | null> {
800
+ const res = await this.runGit(['rev-parse', '--verify', '--quiet', ref], { allowNonZero: true });
801
+ if (res.code !== 0) return null;
802
+ const sha = res.stdout.trim();
803
+ return SHA_RE.test(sha) ? sha : null;
804
+ }
805
+
595
806
  /**
596
807
  * 让工作副本的 origin 与配置的 repoUrl 保持一致(issue #79)。
597
808
  *
@@ -604,14 +815,15 @@ export class GitTransport implements SyncTransport {
604
815
  const current = await this.runGit(['remote', 'get-url', 'origin'], { allowNonZero: true });
605
816
  const origin = current.code === 0 ? current.stdout.trim() : '';
606
817
  if (current.code !== 0) {
607
- await this.runGit(['remote', 'add', 'origin', this.o.repoUrl]);
818
+ await this.runGit(['remote', 'add', 'origin', '--', this.o.repoUrl]);
608
819
  } else if (origin !== '' && origin !== this.o.repoUrl) {
609
- await this.runGit(['remote', 'set-url', 'origin', this.o.repoUrl]);
820
+ await this.runGit(['remote', 'set-url', 'origin', '--', this.o.repoUrl]);
821
+ this.partialClone = null; // 换了远端 ⇒ promisor 判定失效(issue #122)
610
822
  }
611
823
  const push = await this.runGit(['remote', 'get-url', '--push', 'origin'], { allowNonZero: true });
612
824
  const pushUrl = push.code === 0 ? push.stdout.trim() : '';
613
825
  if (pushUrl !== '' && pushUrl !== this.o.repoUrl) {
614
- await this.runGit(['remote', 'set-url', '--push', 'origin', this.o.repoUrl]);
826
+ await this.runGit(['remote', 'set-url', '--push', 'origin', '--', this.o.repoUrl]);
615
827
  }
616
828
  }
617
829
 
@@ -710,8 +922,9 @@ export class GitTransport implements SyncTransport {
710
922
  return {
711
923
  path: this.o.workDir,
712
924
  exists: true,
713
- origin: origin === '' ? null : origin,
714
- pushUrl: pushRaw === '' ? null : pushRaw,
925
+ // RV1-F8:这两个字段会进 GET /sync/status 的响应 → 剥掉可能存在的 userinfo token
926
+ origin: origin === '' ? null : this.bareUrl(origin),
927
+ pushUrl: pushRaw === '' ? null : this.bareUrl(pushRaw),
715
928
  // 读不出 origin(null)→ 不算 match(状态接口据此提示「工作副本地址未知」)
716
929
  match: origin === this.o.repoUrl && (pushRaw === '' || pushRaw === this.o.repoUrl),
717
930
  ahead,
@@ -811,7 +1024,7 @@ export class GitTransport implements SyncTransport {
811
1024
  }
812
1025
  // fetch 是网络操作(幂等读)→ 允许有限指数退避重试
813
1026
  await withSyncRetry(async () => {
814
- await this.runGit(['fetch', 'origin'], { withCredential: true });
1027
+ await this.runGit(['fetch', ...(await this.fetchOptionFlags()), 'origin'], { withCredential: true });
815
1028
  }, this.o.retry);
816
1029
  const remoteRef = `refs/remotes/origin/${branch}`;
817
1030
  const refRes = await this.runGit(['rev-parse', '--verify', '--quiet', remoteRef], { allowNonZero: true });
@@ -879,6 +1092,12 @@ export class GitTransport implements SyncTransport {
879
1092
  const attempt = async (): Promise<void> => {
880
1093
  const branch = await this.currentBranch();
881
1094
  if (branch === '') return; // detached HEAD:没有 upstream 概念(与原「no tracking information」同姿态跳过)
1095
+ // issue #122:稳态(远端没变)时**连 fetch 都不发**。一次 ls-remote 只回一行 refs(~1 KB),
1096
+ // 而 fetch 即使没有新内容也要协商 refs、且远端仓库远大于同步内容时要遍历完整对象图
1097
+ // (报告者现场:1.8 GB 仓库 / 6 MB 内容 ⇒ 一次「检查远端变更」卡死)。
1098
+ // 跳过条件必须两个同时成立:远端 sha == 本地远端跟踪引用(没有新对象可拉)**且** HEAD == 该 sha
1099
+ // (本地已合并)。只比 refs 就跳过 merge 会漏掉「上次 fetch 成功、merge 没跑完」的副本 ——
1100
+ // 那种副本没有新对象可拉,但必须继续本地 merge。
882
1101
  // **不再用 `git pull --ff-only`**:它是「fetch 写 FETCH_HEAD + 读 FETCH_HEAD 合并」的两步动作,
883
1102
  // 并发驱动同一个工作副本时 FETCH_HEAD 会留下多行 for-merge 记录,随后每个 pull 都以
884
1103
  // `fatal: Cannot fast-forward to multiple branches.` 失败(本机 5 并发实测全败),
@@ -886,12 +1105,18 @@ export class GitTransport implements SyncTransport {
886
1105
  // 显式 refspec fetch + 显式远端跟踪分支 merge 完全不读 FETCH_HEAD(同样 5 并发实测全成功),
887
1106
  // 语义与 ff-only pull 一致:能快进就快进,不能快进就报「历史分叉」。
888
1107
  // 参数与判定在 utils/git-fast-forward.ts(market 侧共用同一份实现)。
889
- const fetched = await this.runGit(fastForwardFetchArgs(branch), { withCredential: true, allowNonZero: true });
890
- if (fetched.code !== 0) {
891
- const combined = fetched.stderr + ' ' + fetched.stdout;
892
- // 远端还没有这个分支(全新空仓库 / 分支被改名)→ 与「无 upstream」一样静默跳过
893
- if (isMissingRemoteRefError(combined)) return;
894
- await this.throwPullFailure(combined, fetched.stderr);
1108
+ const remoteSha = await this.lsRemoteBranchSha(branch);
1109
+ const localSha = await this.revParseSha(`refs/remotes/origin/${branch}`);
1110
+ if (remoteSha !== null && remoteSha === localSha) {
1111
+ if (await this.revParseSha('HEAD') === remoteSha) return; // 已是最新:零 fetch、零 merge
1112
+ } else {
1113
+ const fetched = await this.runGit(fastForwardFetchArgs(branch, await this.fetchOptionFlags()), { withCredential: true, allowNonZero: true });
1114
+ if (fetched.code !== 0) {
1115
+ const combined = fetched.stderr + ' ' + fetched.stdout;
1116
+ // 远端还没有这个分支(全新空仓库 / 分支被改名)→ 与「无 upstream」一样静默跳过
1117
+ if (isMissingRemoteRefError(combined)) return;
1118
+ await this.throwPullFailure(combined, fetched.stderr);
1119
+ }
895
1120
  }
896
1121
  const merged = await this.runGit(fastForwardMergeArgs(branch), { allowNonZero: true });
897
1122
  if (merged.code === 0) return;
@@ -938,6 +1163,12 @@ export class GitTransport implements SyncTransport {
938
1163
  opts: { cwd?: string; withCredential?: boolean; allowNonZero?: boolean; requirePushToken?: boolean } = {},
939
1164
  ): Promise<GitExecResult> {
940
1165
  const cwd = opts.cwd === undefined ? this.o.workDir : opts.cwd;
1166
+ // 网络子命令一律带低速上限:真卡住在 30 s 内明确失败,而不是耗满 timeoutMs(issue #122)
1167
+ const networkArgs = NETWORK_SUBCOMMANDS.has(args[0] ?? '') ? [...LOW_SPEED_ARGS] : [];
1168
+ // SEC-12:协议护栏恒带(见 PROTOCOL_GUARD_ARGS);网络仓库再额外禁 file 传输。
1169
+ const guardArgs = this.networkRepo
1170
+ ? [...PROTOCOL_GUARD_ARGS, ...FILE_PROTOCOL_GUARD_ARGS]
1171
+ : [...PROTOCOL_GUARD_ARGS];
941
1172
  let extra: string[] = [];
942
1173
  let token: string | null = null;
943
1174
  let cleanup: (() => Promise<void>) | null = null;
@@ -956,7 +1187,7 @@ export class GitTransport implements SyncTransport {
956
1187
  // 而目录里原本就有别的内容时宁可放弃重试,也绝不删用户文件。
957
1188
  const cloneTargetWasEmpty = args[0] === 'clone' ? await isEmptyDir(cwd) : false;
958
1189
  try {
959
- let result = await this.o.exec(this.o.gitBin, [...extra, ...args], { cwd, timeoutMs: this.o.timeoutMs });
1190
+ let result = await this.o.exec(this.o.gitBin, [...extra, ...guardArgs, ...networkArgs, ...args], { cwd, timeoutMs: this.o.timeoutMs });
960
1191
  // issue #120:credential helper 必须经 shell 执行(值里不含空格也一样,git 统一拼成
961
1192
  // `sh -c 'git <helper> ...'`),而 Android 上 git 二进制的 GIT_SHELL_PATH 指向 App 沙箱里
962
1193
  // 不存在的 Termux sh;`core.askpass` 又要 exec 权限(Android 10+ 对 targetSdk ≥ 29 禁执行应用
@@ -966,33 +1197,11 @@ export class GitTransport implements SyncTransport {
966
1197
  const headerEnv = credentialHeaderEnv(this.o.repoUrl, this.o.credentialUsername, token);
967
1198
  if (Object.keys(headerEnv).length > 0 && (args[0] !== 'clone' || cloneTargetWasEmpty)) {
968
1199
  if (args[0] === 'clone') await clearDirContents(cwd);
969
- result = await this.o.exec(this.o.gitBin, args, { cwd, timeoutMs: this.o.timeoutMs, env: headerEnv });
1200
+ result = await this.o.exec(this.o.gitBin, [...guardArgs, ...networkArgs, ...args], { cwd, timeoutMs: this.o.timeoutMs, env: headerEnv });
970
1201
  }
971
1202
  }
972
1203
  if (result.code !== 0 && !opts.allowNonZero) {
973
- const combined = result.stderr + ' ' + result.stdout;
974
- // 统一分类(超时 / 网络 / 鉴权 / 5xx…):上层可据 kind / retryable 分流,不必解析 message
975
- const classified = classifyNetworkErrorText(combined);
976
- // issue #79:SSH 主机密钥校验失败 → 换成「怎么信任该主机」的可操作说明(绝不自动信任)
977
- if (SSH_HOST_KEY_RE.test(combined)) {
978
- throw new GitTransportError(this.hostKeyMessage(), { kind: 'auth', retryable: false });
979
- }
980
- // 推送 + 鉴权失败:git 原文只说「认证失败」——换成能照做的说明(令牌失效 / 无写权限 / 远端禁用了密码推送)
981
- // issue #115:clone 的鉴权失败原文(remote: Write access to repository not granted / 403)读不出「缺哪种权限」,
982
- // 换成点名 Contents: Read and write 的说明 —— 否则用户只会以为令牌坏了(细粒度 PAT 常只勾了 Administration)
983
- // issue #120:helper 根本没能执行时,「请确认令牌有效」是错误指引(令牌可能是好的)——
984
- // 这类失败必须点名平台原因,排在 push/clone 的鉴权文案之前。
985
- const credentialBlocked = isCredentialExecFailure(combined);
986
- const message = credentialBlocked
987
- ? this.msg('sync.git.credentialHelperBlocked', { err: this.mask(result.stderr, token) })
988
- : opts.requirePushToken === true && classified.kind === 'auth'
989
- ? this.msg('sync.git.pushAuthFailed', { err: this.mask(result.stderr, token) })
990
- : args[0] === 'clone' && classified.kind === 'auth'
991
- ? this.msg('sync.git.cloneAuthFailed', { err: this.mask(result.stderr, token) })
992
- : this.msg('sync.git.cmdFailed', { args: args.join(' '), code: String(result.code), err: this.mask(result.stderr, token) });
993
- // 归类同样不能交给原文:`cannot exec ...: No such file or directory` 里的 ENOENT 会被判成
994
- // notfound(「仓库不存在」),而真实原因是凭据通道不可用 → 钉成 auth(非瞬时、别重试)。
995
- throw new GitTransportError(message, credentialBlocked ? { kind: 'auth', retryable: false } : classified);
1204
+ this.raiseGitFailure(args, result, token, opts.requirePushToken === true);
996
1205
  }
997
1206
  return result;
998
1207
  } catch (err) {
@@ -1000,7 +1209,8 @@ export class GitTransport implements SyncTransport {
1000
1209
  // 注入的 exec 实现可能直接抛错(默认实现只返回非零 code)——同样归一为带分类的传输层错误
1001
1210
  const detail = err instanceof Error ? err.message : String(err);
1002
1211
  throw new GitTransportError(
1003
- this.msg('sync.git.cmdFailed', { args: args.join(' '), code: 'exec', err: this.mask(detail, token) }),
1212
+ // argv 里含 repoUrl 原文(clone/ls-remote/remote set-url -- <url>)→ 必须经 mask 才进文案
1213
+ this.msg('sync.git.cmdFailed', { args: this.mask(args.join(' '), token), code: 'exec', err: this.mask(detail, token) }),
1004
1214
  { ...classifyNetworkErrorText(detail), cause: err },
1005
1215
  );
1006
1216
  } finally {
@@ -1008,6 +1218,36 @@ export class GitTransport implements SyncTransport {
1008
1218
  }
1009
1219
  }
1010
1220
 
1221
+ /**
1222
+ * 把「git 退出非零」归一成可操作错误(runGit 与 clone 回落共用同一套文案与分类)。
1223
+ * token 只用于脱敏,绝不进 message。
1224
+ */
1225
+ private raiseGitFailure(args: string[], result: GitExecResult, token: string | null, requirePushToken: boolean): never {
1226
+ const combined = result.stderr + ' ' + result.stdout;
1227
+ // 统一分类(超时 / 网络 / 鉴权 / 5xx…):上层可据 kind / retryable 分流,不必解析 message
1228
+ const classified = classifyNetworkErrorText(combined);
1229
+ // issue #79:SSH 主机密钥校验失败 → 换成「怎么信任该主机」的可操作说明(绝不自动信任)
1230
+ if (SSH_HOST_KEY_RE.test(combined)) {
1231
+ throw new GitTransportError(this.hostKeyMessage(), { kind: 'auth', retryable: false });
1232
+ }
1233
+ // 推送 + 鉴权失败:git 原文只说「认证失败」——换成能照做的说明(令牌失效 / 无写权限 / 远端禁用了密码推送)
1234
+ // issue #115:clone 的鉴权失败原文(remote: Write access to repository not granted / 403)读不出「缺哪种权限」,
1235
+ // 换成点名 Contents: Read and write 的说明 —— 否则用户只会以为令牌坏了(细粒度 PAT 常只勾了 Administration)
1236
+ // issue #120:helper 根本没能执行时,「请确认令牌有效」是错误指引(令牌可能是好的)——
1237
+ // 这类失败必须点名平台原因,排在 push/clone 的鉴权文案之前。
1238
+ const credentialBlocked = isCredentialExecFailure(combined);
1239
+ const message = credentialBlocked
1240
+ ? this.msg('sync.git.credentialHelperBlocked', { err: this.mask(result.stderr, token) })
1241
+ : requirePushToken && classified.kind === 'auth'
1242
+ ? this.msg('sync.git.pushAuthFailed', { err: this.mask(result.stderr, token) })
1243
+ : args[0] === 'clone' && classified.kind === 'auth'
1244
+ ? this.msg('sync.git.cloneAuthFailed', { err: this.mask(result.stderr, token) })
1245
+ : this.msg('sync.git.cmdFailed', { args: this.mask(args.join(' '), token), code: String(result.code), err: this.mask(result.stderr, token) });
1246
+ // 归类同样不能交给原文:`cannot exec ...: No such file or directory` 里的 ENOENT 会被判成
1247
+ // notfound(「仓库不存在」),而真实原因是凭据通道不可用 → 钉成 auth(非瞬时、别重试)。
1248
+ throw new GitTransportError(message, credentialBlocked ? { kind: 'auth', retryable: false } : classified);
1249
+ }
1250
+
1011
1251
  /**
1012
1252
  * 为网络命令构造 credential helper 参数:
1013
1253
  * 仅 http(s) 远端需要 token;本地路径 / ssh 走 git 原生认证(密钥),不调用 provider。
@@ -1050,7 +1290,8 @@ export class GitTransport implements SyncTransport {
1050
1290
  const u = new URL(this.o.repoUrl);
1051
1291
  return u.port ? `${u.hostname}:${u.port}` : u.hostname;
1052
1292
  } catch {
1053
- throw new GitTransportError(this.msg('sync.git.repoUrlInvalid', { url: this.o.repoUrl }));
1293
+ // RV1-F8:回显一律占位符 —— repoUrl 可能带 userinfo token,错误消息会进日志/响应/issue 截图
1294
+ throw new GitTransportError(this.msg('sync.git.repoUrlInvalid', { url: REPO_URL_PLACEHOLDER }));
1054
1295
  }
1055
1296
  }
1056
1297
 
@@ -1060,6 +1301,29 @@ export class GitTransport implements SyncTransport {
1060
1301
  try { return await this.o.credentials.getToken(); } catch { return null; }
1061
1302
  }
1062
1303
 
1304
+ /**
1305
+ * 回传体里的地址(工作副本自检的 origin / pushUrl)脱敏:只剥「凭据段」,其余原样保留。
1306
+ *
1307
+ * 为什么不整串换占位符:状态面板要显示「工作副本现在指向哪个远端」,用户靠 host/path 判断是否
1308
+ * 已跟随配置。为什么不一律剥 userinfo:`ssh://git@host` 里的 `git` 是 ssh 登录名(标准写法),
1309
+ * 剥掉会让用户认不出自己的远端 —— 与 validateRepoUrl 的口径保持一致(只有 password 位是凭据)。
1310
+ */
1311
+ private bareUrl(url: string): string {
1312
+ try {
1313
+ const parsed = new URL(url);
1314
+ const scheme = parsed.protocol.replace(/:$/, '').toLowerCase();
1315
+ const keepSshUser = scheme === 'ssh' && parsed.password === '';
1316
+ const dirty = parsed.password !== '' || (parsed.username !== '' && !keepSshUser);
1317
+ if (!dirty) return url;
1318
+ parsed.username = '';
1319
+ parsed.password = '';
1320
+ return parsed.toString();
1321
+ } catch {
1322
+ // 解析不了(scp 式 `git@host:path` 或本机路径):`git@` 是登录名不是凭据,原样返回
1323
+ return url;
1324
+ }
1325
+ }
1326
+
1063
1327
  /** 错误/日志脱敏:token(原文与 URL 编码两种形态)与 repoUrl 一律替换 */
1064
1328
  private mask(text: string, token: string | null): string {
1065
1329
  let out = text.split(this.o.repoUrl).join('[REPO_URL]');