Ctrl K
ComunidadGuías…

desktop/electron/gitOperations.cjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
const { execFile } = require('child_process')
const crypto = require('crypto')
const fs = require('fs')
const path = require('path')
const { resolveRepoDir, collectChangedFiles } = require('./gitStatus.cjs')

const GIT_TIMEOUT_MS = 60_000
/** 测试可用 MAO_TEST_GIT_TIMEOUT_MS 覆盖以缩短挂起类用例的等待时间(小于 1s 视为无效)。 */
function gitTimeoutMs() {
  const override = Number(process.env.MAO_TEST_GIT_TIMEOUT_MS)
  return Number.isFinite(override) && override >= 1000 ? override : GIT_TIMEOUT_MS
}
const MAX_GIT_OUTPUT_BYTES = 2 * 1024 * 1024
const MAX_COMMIT_DIFF_BYTES = 200 * 1024
const MAX_COMMIT_MESSAGE_BYTES = 64 * 1024
const writeLocks = new Set()
/** 分支名合法性(git check-ref-format 子集),与后端 resolveSwitchTarget / 前端 isValidBranchName 同规则 */
const BRANCH_NAME_PATTERN = /^[A-Za-z0-9._/-]+$/
const STASH_REF_PATTERN = /^stash@\{\d+\}$/
const STASH_ACTIONS = ['apply', 'pop', 'drop']

function runGit(cwd, args, options = {}) {
  return new Promise((resolve) => {
    let settled = false
    const finish = (result) => {
      if (settled) return
      settled = true
      resolve(result)
    }
    const child = execFile('git', ['-c', 'core.quotepath=false', ...args], {
      cwd,
      encoding: 'utf8',
      maxBuffer: MAX_GIT_OUTPUT_BYTES,
      timeout: gitTimeoutMs(),
      killSignal: 'SIGKILL',
      env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
    }, (error, stdout, stderr) => {
      finish({
        exitCode: error ? (typeof error.code === 'number' ? error.code : 1) : 0,
        stdout: stdout || '',
        stderr: stderr || '',
        timedOut: Boolean(error && (error.killed || error.code === 'ETIMEDOUT')),
      })
    })
    child.on('error', (error) => finish({ exitCode: 127, stdout: '', stderr: error.message || '', timedOut: false }))
    if (options.input != null) {
      child.stdin.end(options.input)
    }
  })
}

function sanitizeError(text) {
  const value = String(text || '').replace(/https?:\/\/[^\s/@]+:[^\s/@]+@/gi, 'https://***@').trim()
  return value.slice(0, 4000)
}

function operationError(operation, message, extra = {}) {
  return { success: false, operation, error: message, ...extra }
}

function describeGitFailure(result, fallback) {
  if (result.timedOut) return `Git 操作超过 ${Math.round(gitTimeoutMs() / 1000)} 秒,已终止`
  const detail = sanitizeError(result.stderr || result.stdout)
  if (/index\.lock|another git process/i.test(detail)) return 'Git index lock 被其他进程占用'
  if (/authentication failed|could not read username|permission denied \(publickey\)|terminal prompts disabled/i.test(detail)) {
    return 'Git 认证失败,请在本机配置 Git 凭证'
  }
  if (/non-fast-forward|fetch first|rejected/i.test(detail)) return '推送被拒绝(non-fast-forward),请先拉取并处理远端变更'
  return detail || fallback
}

async function resolveRepository(workspace, repoPath) {
  if (!workspace) throw new Error('工作区无效')
  const cwd = resolveRepoDir(workspace, repoPath)
  const root = await runGit(cwd, ['rev-parse', '--show-toplevel'])
  if (root.exitCode !== 0 || !root.stdout.trim()) throw new Error('当前工作区不是 Git 仓库')
  return path.resolve(root.stdout.trim())
}

async function getRemoteState(repoRoot) {
  const [branchResult, remotesResult, upstreamResult] = await Promise.all([
    runGit(repoRoot, ['symbolic-ref', '--quiet', '--short', 'HEAD']),
    runGit(repoRoot, ['remote']),
    runGit(repoRoot, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}']),
  ])
  const remotes = remotesResult.exitCode === 0
    ? remotesResult.stdout.split(/\r?\n/).map((item) => item.trim()).filter(Boolean)
    : []
  const branch = branchResult.exitCode === 0 ? branchResult.stdout.trim() : undefined
  return {
    branch,
    remotes,
    hasRemote: remotes.length > 0,
    detachedHead: !branch,
    upstream: upstreamResult.exitCode === 0 ? upstreamResult.stdout.trim() || undefined : undefined,
  }
}

function isSensitivePath(filePath) {
  const normalized = String(filePath || '').replace(/\\/g, '/').toLowerCase()
  const name = normalized.slice(normalized.lastIndexOf('/') + 1)
  if (name === '.env' || name.startsWith('.env.')) return true
  if (/\.(pem|key|p12|pfx)$/.test(name)) return true
  if (/^id_(rsa|dsa|ecdsa|ed25519)(?:[._-].*)?$/.test(name)) return true
  if (/(credential|credentials|secret|secrets)/.test(name)) return true
  return /(?:^|[._-])tokens?(?:[._-]|$)/.test(name)
}

function sliceUtf8(text, maxBytes) {
  if (maxBytes <= 0) return ''
  const buffer = Buffer.from(text, 'utf8')
  if (buffer.length <= maxBytes) return text
  let end = maxBytes
  while (end > 0 && (buffer[end] & 0xc0) === 0x80) end--
  return buffer.subarray(0, end).toString('utf8')
}

function fairlyTruncateDiffs(entries, maxBytes = MAX_COMMIT_DIFF_BYTES) {
  const active = entries.filter((entry) => entry.diff)
  const allocations = new Map(active.map((entry) => [entry, 0]))
  let remaining = maxBytes
  let pending = active.slice()
  while (remaining > 0 && pending.length > 0) {
    const share = Math.floor(remaining / pending.length)
    let consumed = 0
    const next = []
    for (const entry of pending) {
      const current = allocations.get(entry) || 0
      const total = Buffer.byteLength(entry.diff, 'utf8')
      const allowance = share > 0 ? share : (consumed < remaining ? 1 : 0)
      const add = Math.min(allowance, total - current)
      allocations.set(entry, current + add)
      consumed += add
      if (current + add < total) next.push(entry)
    }
    if (consumed === 0) break
    remaining -= consumed
    pending = next
  }

  let diffBytes = 0
  for (const entry of entries) {
    if (!entry.diff) continue
    const originalBytes = Buffer.byteLength(entry.diff, 'utf8')
    const allocation = allocations.get(entry) || 0
    entry.diff = sliceUtf8(entry.diff, allocation)
    const actualBytes = Buffer.byteLength(entry.diff, 'utf8')
    diffBytes += actualBytes
    if (actualBytes < originalBytes) entry.truncated = true
    if (!entry.diff) delete entry.diff
  }
  return { diffBytes, truncated: entries.some((entry) => entry.truncated) }
}

async function buildFileDiff(repoRoot, file) {
  if (file.untracked) {
    const absolute = path.resolve(repoRoot, file.path)
    if ((absolute !== repoRoot && !absolute.startsWith(repoRoot + path.sep)) || !fs.existsSync(absolute)) return ''
    const size = fs.statSync(absolute).size
    const limit = Math.min(size, MAX_COMMIT_DIFF_BYTES + 1)
    const handle = fs.openSync(absolute, 'r')
    const content = Buffer.alloc(limit)
    try {
      fs.readSync(handle, content, 0, limit, 0)
    } finally {
      fs.closeSync(handle)
    }
    if (content.includes(0)) return null
    return `diff --git a/${file.path} b/${file.path}\nnew file mode 100644\n--- /dev/null\n+++ b/${file.path}\n@@ -0,0 +1 @@\n${content.toString('utf8').split('\n').map((line) => `+${line}`).join('\n')}`
  }
  let result = await runGit(repoRoot, ['diff', '--no-ext-diff', '--no-textconv', 'HEAD', '--', file.path])
  if (result.exitCode !== 0) {
    const head = await runGit(repoRoot, ['rev-parse', '--verify', 'HEAD'])
    if (head.exitCode !== 0) {
      result = await runGit(repoRoot, ['diff', '--cached', '--no-ext-diff', '--no-textconv', '--', file.path])
    }
  }
  if (result.exitCode !== 0) return ''
  if (/^Binary files .* differ$/m.test(result.stdout) || /^GIT binary patch$/m.test(result.stdout)) return null
  return result.stdout
}

async function buildCommitInput(workspace, repoPath) {
  let repoRoot
  try {
    repoRoot = await resolveRepository(workspace, repoPath)
  } catch (error) {
    return { error: error.message || '读取 Git 变更失败' }
  }
  const changed = Array.from((await collectChangedFiles(repoRoot)).values())
  const files = []
  for (const file of changed) {
    const sensitive = isSensitivePath(file.path) || (file.oldPath && isSensitivePath(file.oldPath))
    const item = {
      path: file.path,
      changeType: file.changeType,
      insertions: file.insertions || 0,
      deletions: file.deletions || 0,
      binary: Boolean(file.binary),
      sensitive: Boolean(sensitive),
      truncated: false,
    }
    if (!item.binary && !item.sensitive) {
      const diff = await buildFileDiff(repoRoot, file)
      if (diff == null) item.binary = true
      else if (diff) item.diff = diff
    }
    files.push(item)
  }
  const summary = fairlyTruncateDiffs(files)
  return { files, truncated: summary.truncated, diffBytes: summary.diffBytes }
}

function validateCommitMessage(message) {
  if (typeof message !== 'string' || !message.trim()) throw new Error('提交信息不能为空')
  if (Buffer.byteLength(message, 'utf8') > MAX_COMMIT_MESSAGE_BYTES) throw new Error('提交信息过长')
  if (message.includes('\0')) throw new Error('提交信息包含非法字符')
  const normalized = message.replace(/\r\n/g, '\n').trim()
  return { normalized, title: normalized.split('\n')[0] }
}

async function withWriteLock(workspace, repoPath, operation, callback) {
  let repoRoot
  try {
    repoRoot = await resolveRepository(workspace, repoPath)
  } catch (error) {
    return operationError(operation, error.message || '当前工作区不是 Git 仓库')
  }
  const lockKey = process.platform === 'win32' ? repoRoot.toLowerCase() : repoRoot
  if (writeLocks.has(lockKey)) return operationError(operation, 'Git 操作进行中')
  writeLocks.add(lockKey)
  try {
    return await callback(repoRoot)
  } catch (error) {
    return operationError(operation, sanitizeError(error.message) || 'Git 操作失败')
  } finally {
    writeLocks.delete(lockKey)
  }
}

async function commit(workspace, repoPath, message) {
  return withWriteLock(workspace, repoPath, 'commit', async (repoRoot) => {
    let commitMessage
    try {
      commitMessage = validateCommitMessage(message)
    } catch (error) {
      return operationError('commit', error.message)
    }
    const status = await runGit(repoRoot, ['status', '--porcelain', '--untracked-files=all'])
    if (status.exitCode !== 0) return operationError('commit', describeGitFailure(status, '读取 Git 状态失败'))
    if (!status.stdout.trim()) return operationError('commit', '没有待提交的变更')

    const add = await runGit(repoRoot, ['add', '-A'])
    if (add.exitCode !== 0) return operationError('commit', describeGitFailure(add, 'Git add 失败'))

    const [name, email] = await Promise.all([
      runGit(repoRoot, ['config', '--get', 'user.name']),
      runGit(repoRoot, ['config', '--get', 'user.email']),
    ])
    const identityArgs = name.exitCode === 0 && name.stdout.trim() && email.exitCode === 0 && email.stdout.trim()
      ? []
      : ['-c', 'user.name=Mao Agent', '-c', 'user.email=mao@etarch.cn']
    // 整条消息作为单个 -m:拆成 title/body 两个 -m 时 git 会用空行拼接,会改写用户原文
    const result = await runGit(repoRoot, [...identityArgs, 'commit', '--no-verify', '-m', commitMessage.normalized])
    if (result.exitCode !== 0) return operationError('commit', describeGitFailure(result, 'Git commit 失败'))

    const hash = await runGit(repoRoot, ['rev-parse', '--short', 'HEAD'])
    const branchState = await getRemoteState(repoRoot)
    return {
      success: true,
      operation: 'commit',
      message: `提交成功 ${hash.stdout.trim()}:${commitMessage.title}`,
      branch: branchState.branch,
      commitHash: hash.stdout.trim(),
      commitTitle: commitMessage.title,
    }
  })
}

async function hasConflicts(repoRoot) {
  const result = await runGit(repoRoot, ['diff', '--name-only', '--diff-filter=U'])
  return result.exitCode === 0 && Boolean(result.stdout.trim())
}

async function findStashRef(repoRoot, objectId) {
  const list = await runGit(repoRoot, ['stash', 'list', '--format=%H%x09%gd'])
  if (list.exitCode !== 0) return undefined
  for (const line of list.stdout.split(/\r?\n/)) {
    const [oid, ref] = line.split('\t')
    if (oid === objectId) return ref
  }
  return undefined
}

async function restoreStash(repoRoot, objectId, stashLabel) {
  const apply = await runGit(repoRoot, ['stash', 'apply', '--index', objectId])
  if (apply.exitCode !== 0) {
    const conflict = await hasConflicts(repoRoot)
    return { success: false, conflict, error: `自动恢复本地变更失败,已保留 ${stashLabel}(${objectId.slice(0, 12)}):${describeGitFailure(apply, 'stash apply 失败')}` }
  }
  const stashRef = await findStashRef(repoRoot, objectId)
  if (!stashRef) return { success: false, conflict: false, error: `本地变更已恢复,但无法定位并清理 ${stashLabel}(${objectId.slice(0, 12)})` }
  const drop = await runGit(repoRoot, ['stash', 'drop', stashRef])
  if (drop.exitCode !== 0) return { success: false, conflict: false, error: `本地变更已恢复,但清理 ${stashRef} 失败` }
  return { success: true }
}

async function pull(workspace, repoPath) {
  return withWriteLock(workspace, repoPath, 'pull', async (repoRoot) => {
    const remoteState = await getRemoteState(repoRoot)
    if (remoteState.detachedHead) return operationError('pull', 'detached HEAD 无法拉取,请先切换分支')
    if (!remoteState.hasRemote) return operationError('pull', '仓库未配置远端')

    const dirty = await runGit(repoRoot, ['status', '--porcelain', '--untracked-files=all'])
    if (dirty.exitCode !== 0) return operationError('pull', describeGitFailure(dirty, '读取 Git 状态失败'))
    let stashId
    let stashLabel
    if (dirty.stdout.trim()) {
      stashLabel = `mao-auto-pull-${crypto.randomUUID()}`
      const before = await runGit(repoRoot, ['rev-parse', '--verify', '--quiet', 'refs/stash'])
      const stash = await runGit(repoRoot, ['stash', 'push', '--include-untracked', '-m', stashLabel])
      if (stash.exitCode !== 0) return operationError('pull', describeGitFailure(stash, '创建自动 stash 失败'))
      const after = await runGit(repoRoot, ['rev-parse', '--verify', 'refs/stash'])
      if (after.exitCode !== 0 || after.stdout.trim() === before.stdout.trim()) {
        return operationError('pull', '创建自动 stash 失败,工作区可能在操作期间发生变化')
      }
      stashId = after.stdout.trim()
    }

    const pulled = await runGit(repoRoot, ['pull', '--no-edit'])
    const mergeHead = pulled.exitCode !== 0
      ? await runGit(repoRoot, ['rev-parse', '--verify', '--quiet', 'MERGE_HEAD'])
      : { exitCode: 1 }
    const pullConflict = pulled.exitCode !== 0 && (await hasConflicts(repoRoot) || mergeHead.exitCode === 0)
    if (pullConflict) {
      return operationError('pull', `拉取进入未完成合并状态,已保留 ${stashLabel || '当前合并现场'}${stashId ? `(${stashId.slice(0, 12)})` : ''}`, {
        branch: remoteState.branch,
        stashRef: stashId,
        conflict: true,
      })
    }

    if (stashId) {
      const restored = await restoreStash(repoRoot, stashId, stashLabel)
      if (!restored.success) {
        return operationError('pull', restored.error, { branch: remoteState.branch, stashRef: stashId, conflict: restored.conflict })
      }
    }
    if (pulled.exitCode !== 0) {
      return operationError('pull', describeGitFailure(pulled, 'Git pull 失败'), {
        branch: remoteState.branch,
        stashRef: stashId,
      })
    }
    return { success: true, operation: 'pull', message: '拉取成功', branch: remoteState.branch }
  })
}

async function push(workspace, repoPath) {
  return withWriteLock(workspace, repoPath, 'push', async (repoRoot) => {
    const state = await getRemoteState(repoRoot)
    if (state.detachedHead) return operationError('push', 'detached HEAD 无法推送,请先切换分支')
    if (!state.hasRemote) return operationError('push', '仓库未配置远端')

    let args = ['push']
    if (!state.upstream) {
      let remote
      if (state.remotes.includes('origin')) remote = 'origin'
      else if (state.remotes.length === 1) remote = state.remotes[0]
      else return operationError('push', '存在多个 remote 且未配置 upstream,请先手动配置')
      args = ['push', '--set-upstream', remote, state.branch]
    }
    const result = await runGit(repoRoot, args)
    if (result.exitCode !== 0) return operationError('push', describeGitFailure(result, 'Git push 失败'), { branch: state.branch })
    return { success: true, operation: 'push', message: '推送成功', branch: state.branch }
  })
}

/**
 * 解析切换分支目标(与后端 resolveSwitchTarget 同规则):
 * <remote>/<branch> → git switch -c <local> --track <remote>/<branch>(不依赖 git switch 的 DWIM);
 * create=true 且为普通分支名 → 基于当前 HEAD 新建本地分支 git switch -c <name>;
 * 其余 → git switch <branch>。
 * 本地新建路径的名字直接拼进 argv,首字符 `-` 会被 git 当 option 解析(option 注入),
 * 且与前端 isValidBranchName 的首字符规则不一致,故显式拒绝;普通切换路径校验保持不动。
 */
function resolveSwitchTarget(branch, create, remotes) {
  const name = String(branch || '').trim()
  const valid = name.length > 0
    && name.length <= 200
    && BRANCH_NAME_PATTERN.test(name)
    && !name.includes('..')
    && !name.includes('@{')
    && name !== '@'
    && !name.startsWith('/')
    && !name.endsWith('/')
    && !name.includes('//')
    && !name.endsWith('.')
    && name.split('/').every((part) => part.length > 0 && !part.startsWith('.') && !part.endsWith('.lock'))
  if (!valid) throw new Error('分支名无效')
  const slashIdx = name.indexOf('/')
  const remotePrefix = slashIdx > 0 ? name.slice(0, slashIdx) : ''
  const isRemoteRef = remotePrefix.length > 0 && remotes.includes(remotePrefix)
  if (isRemoteRef) {
    const local = name.slice(slashIdx + 1)
    return { args: ['switch', '-c', local, '--track', name], localBranch: local }
  }
  if (create) {
    if (name.startsWith('-')) throw new Error('分支名无效')
    return { args: ['switch', '-c', name], localBranch: name }
  }
  return { args: ['switch', name], localBranch: name }
}

/**
 * 显式获取远端更新(并行执行逐远端 fetch --prune,不动工作区)。
 * 获取全部远端:多远端仓库下非 upstream 远端的数据不再停留在旧状态;每个远端一个
 * 独立子进程并行执行,单个挂起不阻断其余远端,总等待以单个远端超时(60s)为界。
 * @param {string} workspace
 * @param {string} [repoPath]
 */
async function fetch(workspace, repoPath) {
  return withWriteLock(workspace, repoPath, 'fetch', async (repoRoot) => {
    const state = await getRemoteState(repoRoot)
    if (!state.hasRemote) return operationError('fetch', '仓库未配置远端')
    // 并行 fetch 全部远端(每个远端一个独立子进程、各自 60s 超时):任意多个远端挂起时
    // 总耗时以单个远端超时为上界(串行会叠成 N×60s);单个远端挂起/失败不影响其余远端,
    // 失败也天然归属到具体远端(无需解析 git 输出)。部分失败按成功提示,全部失败才报错。
    const results = await Promise.all(state.remotes.map(async (remote) => ({
      remote,
      result: await runGit(repoRoot, ['fetch', '--prune', remote]),
    })))
    const failures = results.filter((entry) => entry.result.exitCode !== 0)
    if (failures.length === state.remotes.length) {
      return operationError('fetch', failures.length === 1
        ? describeGitFailure(failures[0].result, '获取远端更新失败')
        : `全部远端获取失败:${failures.map((f) => f.remote).join('、')}`, { branch: state.branch })
    }
    if (failures.length > 0) {
      return {
        success: true,
        operation: 'fetch',
        message: `部分远端已更新(${failures.map((f) => f.remote).join('、')} 获取失败)`,
        branch: state.branch,
      }
    }
    return { success: true, operation: 'fetch', message: '远端已更新', branch: state.branch }
  })
}

/**
 * 切换分支:stash → switch → 恢复,与 pull 的 stash 生命周期逐行同构。
 * 守卫只查 detached HEAD,不要求配置 remote(无远端的纯本地仓库也应可切分支)。
 * 切换失败或恢复冲突均保留现场,错误消息含 stash 引用。
 * @param {string} workspace
 * @param {string} [repoPath]
 * @param {string} branch
 * @param {boolean} [create]
 */
async function switchBranch(workspace, repoPath, branch, create) {
  return withWriteLock(workspace, repoPath, 'switch', async (repoRoot) => {
    const state = await getRemoteState(repoRoot)
    if (state.detachedHead) return operationError('switch', 'detached HEAD 无法切换分支,请先切换分支')
    let target
    try {
      target = resolveSwitchTarget(branch, create === true, state.remotes)
    } catch (error) {
      return operationError('switch', error.message || '分支名无效')
    }

    const dirty = await runGit(repoRoot, ['status', '--porcelain', '--untracked-files=all'])
    if (dirty.exitCode !== 0) return operationError('switch', describeGitFailure(dirty, '读取 Git 状态失败'))
    let stashId
    let stashLabel
    if (dirty.stdout.trim()) {
      stashLabel = `mao-auto-switch-${crypto.randomUUID()}`
      const before = await runGit(repoRoot, ['rev-parse', '--verify', '--quiet', 'refs/stash'])
      const stash = await runGit(repoRoot, ['stash', 'push', '--include-untracked', '-m', stashLabel])
      if (stash.exitCode !== 0) return operationError('switch', describeGitFailure(stash, '创建自动 stash 失败'))
      const after = await runGit(repoRoot, ['rev-parse', '--verify', 'refs/stash'])
      if (after.exitCode !== 0 || after.stdout.trim() === before.stdout.trim()) {
        return operationError('switch', '创建自动 stash 失败,工作区可能在操作期间发生变化')
      }
      stashId = after.stdout.trim()
    }

    const switched = await runGit(repoRoot, target.args)
    if (switched.exitCode !== 0) {
      if (stashId) {
        const restored = await restoreStash(repoRoot, stashId, stashLabel)
        if (!restored.success) {
          return operationError('switch', restored.error, { branch: state.branch, stashRef: stashId, conflict: restored.conflict })
        }
      }
      // 同名本地分支已存在:git switch -c 非零退出兜底拦截,专判给出可读文案(不覆盖已有分支)
      if (/already exists/i.test(String(switched.stderr || switched.stdout || ''))) {
        return operationError('switch', '分支已存在,请换一个名字', { branch: state.branch, stashRef: stashId })
      }
      return operationError('switch', describeGitFailure(switched, '切换分支失败'), { branch: state.branch, stashRef: stashId })
    }
    if (stashId) {
      const restored = await restoreStash(repoRoot, stashId, stashLabel)
      if (!restored.success) {
        return operationError('switch', restored.error, { branch: state.branch, stashRef: stashId, conflict: restored.conflict })
      }
    }
    return { success: true, operation: 'switch', message: `已切换到分支 ${target.localBranch}`, branch: target.localBranch }
  })
}

/**
 * stash 操作(apply / pop / drop,白名单枚举)。
 * apply/pop 后检测冲突:有冲突返回 success:false + conflict:true 并保留现场(不自动 reset)。
 * @param {string} workspace
 * @param {string} [repoPath]
 * @param {string} action
 * @param {string} ref
 */
async function stashOp(workspace, repoPath, action, ref) {
  return withWriteLock(workspace, repoPath, 'stash', async (repoRoot) => {
    if (!STASH_ACTIONS.includes(action)) return operationError('stash', 'stash 操作类型无效')
    if (!STASH_REF_PATTERN.test(String(ref || ''))) return operationError('stash', 'stash 引用无效')
    let args
    if (action === 'apply') args = ['stash', 'apply', '--index', ref]
    else if (action === 'pop') args = ['stash', 'pop', '--index', ref]
    else args = ['stash', 'drop', ref]
    const result = await runGit(repoRoot, args)
    // apply/pop 冲突时 git 退出码为 1(预期内):先判冲突走专用契约,再判真失败,
    // 否则冲突永远落进裸 git 错误分支,conflict 标记与「已保留现场」引导丢失
    if (action !== 'drop' && await hasConflicts(repoRoot)) {
      return operationError('stash', `stash ${action === 'apply' ? '应用' : '弹出'}产生冲突,已保留现场,请手动处理(${ref})`, {
        conflict: true,
        stashRef: ref,
      })
    }
    if (result.exitCode !== 0) return operationError('stash', describeGitFailure(result, `stash ${action} 失败`))
    const state = await getRemoteState(repoRoot)
    const label = action === 'apply' ? '已应用' : action === 'pop' ? '已弹出' : '已丢弃'
    return { success: true, operation: 'stash', message: `${label} ${ref}`, branch: state.branch, stashRef: ref }
  })
}

module.exports = {
  MAX_COMMIT_DIFF_BYTES,
  buildCommitInput,
  commit,
  fairlyTruncateDiffs,
  fetch,
  getRemoteState,
  isSensitivePath,
  pull,
  push,
  resolveSwitchTarget,
  stashOp,
  switchBranch,
  validateCommitMessage,
}