dsh-vscode-mode 0.15.0 → 0.15.3

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/src/svn.ts CHANGED
@@ -19,6 +19,7 @@ import { attrOf, numAttrOf, scanXmlTags, textOf, unescapeXml } from './svnXml.js
19
19
  import { TEXT_OUTPUT_CAP, batchResultOf, tailOfText, updateResultOf } from './svnText.js'
20
20
  import { LOG_CAP, LOG_DEFAULT_LIMIT, SVN_LOG_SHOW_ALL_CAP, parseLogXml, parseSvnInfoRevision } from './svnLog.js'
21
21
  import { SvnUpdateJobs } from './svnUpdateJob.js'
22
+ import { conflictOf, hasConflict, pegTarget, validAccept, validTarget } from './svnResolve.js'
22
23
  import { EXPLORER_CAP, explorerViewOf, scopeEntriesOf, statusXmlValid, viewArgsValid } from './svnExplorer.js'
23
24
  import { ExplorerScopes, explorerKey } from './svnExplorerCache.js'
24
25
  import type { ExplorerScan } from './svnExplorerCache.js'
@@ -503,6 +504,14 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
503
504
  const now = deps.now ?? (() => Date.now())
504
505
  const findRoot = deps.findRoot ?? ((cwd: string) => findSvnRoot(cwd))
505
506
  const findTortoise = deps.findTortoise ?? ((dir: string) => findTortoiseProc(dir))
507
+ const conflictWrites = new Set<string>()
508
+ /** @private @author ddj 2026年10月14号 @param root WC root. @returns Canonical lock identity across sessions. */
509
+ const writeKey = (root: string): string => (deps.platform ?? process.platform) === 'win32' ? root.toLowerCase() : root
510
+ /** @private @author ddj 2026年10月14号 @param root WC root. @returns Whether a live update or conflict write owns the WC. */
511
+ const writeBusy = (root: string): boolean => {
512
+ const job = updateJobs.active(root)
513
+ return conflictWrites.has(writeKey(root)) || Boolean(job && ['starting', 'running', 'cancelling'].includes(job.phase))
514
+ }
506
515
  const statusCache = new Map<string, { at: number; payload: SvnStatusPayload }>()
507
516
  /** 运行中更新按会话目录复用工作副本根;TTL 到期才重新扫描 .svn。 */
508
517
  const updateRootCache = new Map<string, { at: number; root: string }>()
@@ -622,7 +631,7 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
622
631
  * @param stdoutCap stdout 收集上限(缺省 1MB;大载荷场景如日志拉取需显式放大)
623
632
  * @returns 退出码与输出
624
633
  */
625
- const runSvn = async (cwd: string, argv: string[], graceMs: number, stdoutCap: number = OUTPUT_CAP): Promise<SvnRunOutcome> => {
634
+ const runSvn = async (cwd: string, argv: string[], graceMs: number, stdoutCap: number = OUTPUT_CAP, allowFallback = true): Promise<SvnRunOutcome> => {
626
635
  const spawn = subprocess()
627
636
  if (!spawn) throw new Error('缺少 subprocess 服务')
628
637
  const fs = ctx.get('fs') as { resolve?: (p: string, o: object) => Promise<unknown>; processPath?: (t: unknown) => string } | null | undefined
@@ -643,7 +652,8 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
643
652
  const stdout = handle.collected?.stdout?.readFrom(0).text ?? ''
644
653
  const stderr = handle.collected?.stderr?.readFrom(0).text ?? ''
645
654
  return { code: outcome?.exitCode ?? outcome?.code ?? null, stdout, stderr }
646
- } catch {
655
+ } catch (error) {
656
+ if (!allowFallback) throw error
647
657
  // 受管环境管道 EPERM:回退 inherit(只拿退出码,输出为空)
648
658
  const handle = attempt('inherit')
649
659
  const outcome = await handle.done
@@ -651,6 +661,15 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
651
661
  }
652
662
  }
653
663
 
664
+ /** @private @author ddj 2026年10月14号 @param cwd Session workspace. @param root WC root. @param path Validated target. @returns Exact conflict evidence without recursive scanning. */
665
+ const readConflict = async (cwd: string, root: string, path: string) => {
666
+ const target = svnPathOf(cwd, root, path)
667
+ if (!target) throw new Error('目标不在 SVN 工作副本内')
668
+ const result = await runSvn(root, ['status', '--xml', '--verbose', '--depth', 'empty', '--', pegTarget(target)], READ_GRACE_MS, OUTPUT_CAP, false)
669
+ if (result.code !== 0) throw new Error(result.stderr || result.stdout || 'SVN 状态读取失败')
670
+ return conflictOf(result.stdout, path)
671
+ }
672
+
654
673
  /** 发射 TortoiseProc(GUI 长驻:不 await done,no-op catch 防未处理 rejection)。 */
655
674
  const launchTortoise = (exe: string, action: SvnAction, absPath: string): void => {
656
675
  const spawn = subprocess()
@@ -738,7 +757,9 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
738
757
  if (!status.managed || !status.wcRoot) return { ok: false as const, error: '当前工作区不受 SVN 管理' }
739
758
  if (!status.svnCli) return { ok: false as const, error: 'svn 命令不可用(可在设置页配置 svn 路径)' }
740
759
  try {
741
- const outcome = await runSvn(status.wcRoot, [...verbArgs, '--', ...rels], MUTATE_GRACE_MS)
760
+ const targets = rels.map((path) => svnPathOf(sc.cwd, status.wcRoot!, path))
761
+ if (targets.some((path) => path === null)) return { ok: false as const, error: '目标不在 SVN 工作副本内' }
762
+ const outcome = await runSvn(status.wcRoot, [...verbArgs, '--', ...targets.map((path) => pegTarget(path!))], MUTATE_GRACE_MS, OUTPUT_CAP, !verbArgs.includes('--depth'))
742
763
  const result = batchResultOf(outcome.stdout, outcome.stderr, outcome.code, verb)
743
764
  // 变更清单已过期(revert/add 改工作副本状态):主动作废缓存,下次拉取即为最新
744
765
  changesCache.delete(status.wcRoot)
@@ -1089,8 +1110,51 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
1089
1110
  return { ok: false as const, error: '读取基线失败:' + String(error) }
1090
1111
  }
1091
1112
  },
1092
- 'svn.revert': async (args: { sessionId?: string; paths?: string[] }) => {
1093
- return mutate('还原', ['revert', '-R'], args.sessionId, args.paths)
1113
+ 'svn.conflictStatus': async (args: { sessionId?: string; paths?: string[] }) => {
1114
+ const scope = await updateScope(args.sessionId, true)
1115
+ if ('err' in scope) return { ok: false as const, error: scope.err }
1116
+ if (!scope.cli) return { ok: false as const, error: 'svn 命令不可用' }
1117
+ if (!Array.isArray(args.paths) || args.paths.length > BATCH_PATHS_CAP) return { ok: false as const, error: '冲突路径列表不合法或超限' }
1118
+ if (!args.paths.every(validTarget)) return { ok: false as const, error: '路径不合法' }
1119
+ const paths = [...new Set(args.paths.map(safeRel))]
1120
+ if (paths.some((path) => !path)) return { ok: false as const, error: '路径不合法' }
1121
+ try {
1122
+ const states = []
1123
+ for (const path of paths) states.push(await readConflict(scope.cwd, scope.root, path!))
1124
+ return { ok: true as const, states }
1125
+ } catch (error) { return { ok: false as const, error: String(error) } }
1126
+ },
1127
+ 'svn.resolve': async (args: { sessionId?: string; path?: string; accept?: unknown }) => {
1128
+ if (!validAccept(args.accept)) return { ok: false as const, error: '解决策略不合法' }
1129
+ if (!validTarget(args.path)) return { ok: false as const, error: '路径不合法' }
1130
+ const path = safeRel(args.path)
1131
+ if (!path) return { ok: false as const, error: '路径不合法' }
1132
+ const scope = await updateScope(args.sessionId, true)
1133
+ if ('err' in scope) return { ok: false as const, error: scope.err }
1134
+ if (!scope.cli) return { ok: false as const, error: 'svn 命令不可用' }
1135
+ if (writeBusy(scope.root)) return { ok: false as const, error: '工作副本正在更新或解决冲突,请等待完成' }
1136
+ conflictWrites.add(writeKey(scope.root))
1137
+ try {
1138
+ const evidence = await readConflict(scope.cwd, scope.root, path)
1139
+ if (!hasConflict(evidence)) return { ok: false as const, error: '目标已无冲突,请刷新状态' }
1140
+ if (evidence.tree && args.accept !== 'working') return { ok: false as const, error: '树冲突不支持通用完整版本选择,请针对性处理' }
1141
+ const target = svnPathOf(scope.cwd, scope.root, path)!
1142
+ const outcome = await runSvn(scope.root, ['resolve', '--accept', args.accept, '--depth', 'empty', '--', pegTarget(target)], MUTATE_GRACE_MS, OUTPUT_CAP, false)
1143
+ if (outcome.code !== 0) return { ok: false as const, error: outcome.stderr || outcome.stdout || 'SVN 解决冲突失败' }
1144
+ return { ok: true as const, summary: 'SVN 解决命令已执行,请核对剩余冲突', output: outcome.stdout }
1145
+ } catch (error) { return { ok: false as const, error: String(error) } }
1146
+ finally { conflictWrites.delete(writeKey(scope.root)); changesCache.delete(scope.root); explorerCache.clear() }
1147
+ },
1148
+ 'svn.revert': async (args: { sessionId?: string; paths?: string[]; depth?: unknown }) => {
1149
+ if (args.depth === undefined) return mutate('还原', ['revert', '-R'], args.sessionId, args.paths)
1150
+ if (args.depth !== 'empty') return { ok: false as const, error: '还原深度不合法' }
1151
+ if (!Array.isArray(args.paths) || !args.paths.length || !args.paths.every(validTarget)) return { ok: false as const, error: '路径不合法' }
1152
+ const scope = await updateScope(args.sessionId, true)
1153
+ if ('err' in scope) return { ok: false as const, error: scope.err }
1154
+ if (writeBusy(scope.root)) return { ok: false as const, error: '工作副本正在更新或解决冲突,请等待完成' }
1155
+ conflictWrites.add(writeKey(scope.root))
1156
+ try { return await mutate('还原', ['revert', '--depth', 'empty'], args.sessionId, args.paths) }
1157
+ finally { conflictWrites.delete(writeKey(scope.root)); changesCache.delete(scope.root); explorerCache.clear() }
1094
1158
  },
1095
1159
  'svn.add': async (args: { sessionId?: string; paths?: string[] }) => {
1096
1160
  return mutate('加入版本控制', ['add'], args.sessionId, args.paths)
@@ -1546,6 +1610,7 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
1546
1610
  const fs = ctx.get('fs') as { resolve?: (p: string, o: object) => Promise<unknown>; processPath?: (t: unknown) => string } | undefined
1547
1611
  const cwd = fs?.resolve && fs?.processPath
1548
1612
  ? fs.processPath(await fs.resolve(scope.cwd, {})) : scope.cwd
1613
+ if (conflictWrites.has(writeKey(scope.root))) return { ok: false as const, error: '工作副本正在解决冲突,请等待完成' }
1549
1614
  return { ok: true as const, ...updateJobs.start({ cwd, root: scope.root, path: rel, exe: scope.exe }) }
1550
1615
  } catch (error) {
1551
1616
  return { ok: false as const, error: '启动 SVN 更新失败:' + String(error) }
@@ -0,0 +1,33 @@
1
+ import type { SvnConflictState, SvnResolveAccept } from './shared/svn.js'
2
+ import { attrOf, scanXmlTags } from './svnXml.js'
3
+
4
+ /** @public @author ddj 2026年10月14号 @param value Requested strategy. @returns Whether SVN accepts this explicit strategy. */
5
+ export function validAccept(value: unknown): value is SvnResolveAccept {
6
+ return value === 'mine-full' || value === 'theirs-full' || value === 'working'
7
+ }
8
+
9
+ /** @public @author ddj 2026年10月14号 @param value Untrusted relative target. @returns Whether the path names exactly one non-root workspace target. */
10
+ export function validTarget(value: unknown): value is string {
11
+ if (typeof value !== 'string' || !value || /[\u0000-\u001f]/.test(value) || /^[A-Za-z]:|^[\\/]/.test(value)) return false
12
+ return value.replace(/\\/g, '/').split('/').every((part) => Boolean(part) && part !== '.' && part !== '..' && part.toLowerCase() !== '.svn')
13
+ }
14
+
15
+ /** @public @author ddj 2026年10月14号 @param path Exact relative target. @returns Peg-safe SVN argument. */
16
+ export function pegTarget(path: string): string { return path.includes('@') ? path + '@' : path }
17
+
18
+ /** @public @author ddj 2026年10月14号 @param xml Verbose depth-empty status. @param path Client target identity. @returns Confirmed conflict evidence. @throws Missing or incomplete status evidence. */
19
+ export function conflictOf(xml: string, path: string): SvnConflictState {
20
+ if (!/^\s*(?:<\?xml[^>]*>\s*)?<status\b/.test(xml) || !/<\/status>\s*$/.test(xml)) {
21
+ throw new Error('SVN 冲突状态输出为空或不完整,状态未确认')
22
+ }
23
+ const statuses = [...scanXmlTags(xml, ['wc-status'])].filter((hit) => !hit.closing)
24
+ if (statuses.length !== 1) throw new Error('SVN 未返回唯一目标状态,状态未确认')
25
+ const attrs = statuses[0].attrs
26
+ const item = attrOf(attrs, 'item')
27
+ if (!item) throw new Error('SVN 目标状态缺失,状态未确认')
28
+ return { path, text: item === 'conflicted', props: attrOf(attrs, 'props') === 'conflicted',
29
+ tree: attrOf(attrs, 'tree-conflicted') === 'true', versioned: !['unversioned', 'ignored', 'external', 'none'].includes(item) }
30
+ }
31
+
32
+ /** @public @author ddj 2026年10月14号 @param state Confirmed target evidence. @returns Whether any conflict remains. */
33
+ export function hasConflict(state: SvnConflictState): boolean { return state.text || state.props || state.tree }