desktop/electron/gitOperations.cjs
Rama main · réplica de lectura del repositorio original.
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,
}