dsh-config-manager 0.2.3 → 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.
@@ -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
- assert.deepEqual(calls[0], ['ls-remote', REPO]);
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 (joined.includes('fetch origin')) {
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(joinedArgs(calls, 'fetch origin').length, 2, '第一次瞬时失败 → 重试一次即成功');
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(joinedArgs(calls, 'fetch origin').length, 3, 'attempts=3 → 恰好 3 次尝试');
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(joinedArgs(calls, 'fetch origin').length, 1, '不可重试错误不得重试: ' + stderr);
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
- const fetched = calls.some((c) => c.args.slice(-3).join(' ') === 'fetch origin +refs/heads/main:refs/remotes/origin/main');
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 (joined.includes('fetch origin')) {
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 (joined.includes('fetch origin')) {
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 };