const test = require('node:test') const assert = require('node:assert/strict') const { execFileSync } = require('child_process') const fs = require('fs') const http = require('node:http') const os = require('os') const path = require('path') const { EXCLUDED_REPO_DIRS, getGitStatus, listGitRepos, refreshGitStatus } = require('./gitStatus.cjs') const { MAX_COMMIT_DIFF_BYTES, buildCommitInput, commit, fairlyTruncateDiffs, fetch, getRemoteState, isSensitivePath, pull, push, resolveSwitchTarget, stashOp, switchBranch, } = require('./gitOperations.cjs') // commit 只在「仓库和全局都没有 user.name/email」时用 -c 注入 Mao Agent,且不写回仓库配置。 // 开发机的全局 git 身份会让这条断言读到真实姓名,这里把本文件的 git 限定在空配置上。 const isolatedGitConfig = path.join(os.tmpdir(), `mao-git-isolated-${process.pid}.config`) fs.writeFileSync(isolatedGitConfig, '') process.env.GIT_CONFIG_GLOBAL = isolatedGitConfig process.env.GIT_CONFIG_SYSTEM = isolatedGitConfig process.on('exit', () => fs.rmSync(isolatedGitConfig, { force: true })) function git(cwd, args, env = {}) { return execFileSync('git', args, { cwd, encoding: 'utf8', env: { ...process.env, ...env } }).trim() } function tempDir(t) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mao-git-test-')) t.after(() => fs.rmSync(dir, { recursive: true, force: true })) return dir } function initRepo(t, withIdentity = true) { const dir = tempDir(t) git(dir, ['init', '-b', 'main']) if (withIdentity) { git(dir, ['config', 'user.name', 'Test User']) git(dir, ['config', 'user.email', 'test@example.com']) } fs.writeFileSync(path.join(dir, 'README.md'), 'initial\n') git(dir, ['add', '-A']) git(dir, ['-c', 'user.name=Setup', '-c', 'user.email=setup@example.com', 'commit', '-m', 'initial']) return dir } function createBareRemote(t) { const remote = tempDir(t) git(remote, ['init', '--bare']) return remote } /** 启动一个「接受连接但永不响应」的本地 HTTP 服务,模拟挂起远端。 */ function startHangingRemote(t) { const sockets = new Set() const server = http.createServer(() => { // 永不响应:请求一直挂起,直到 git 子进程被超时杀掉 }) server.on('connection', (socket) => { sockets.add(socket) socket.on('close', () => sockets.delete(socket)) }) return new Promise((resolve) => { server.listen(0, '127.0.0.1', () => { t.after(() => { for (const socket of sockets) socket.destroy() server.close() }) const { port } = server.address() resolve(`http://127.0.0.1:${port}/repo.git`) }) }) } /** 回调执行期间用 MAO_TEST_GIT_TIMEOUT_MS 缩短 git 子进程超时,避免挂起用例等满 60s。 */ async function withGitTimeout(ms, fn) { const previous = process.env.MAO_TEST_GIT_TIMEOUT_MS process.env.MAO_TEST_GIT_TIMEOUT_MS = String(ms) try { return await fn() } finally { if (previous === undefined) delete process.env.MAO_TEST_GIT_TIMEOUT_MS else process.env.MAO_TEST_GIT_TIMEOUT_MS = previous } } test('repo discovery excludes tool-managed git directories', async (t) => { const workspace = tempDir(t) for (const name of EXCLUDED_REPO_DIRS) { const dir = path.join(workspace, name) fs.mkdirSync(dir) git(dir, ['init']) } const project = path.join(workspace, 'business-project') fs.mkdirSync(project) git(project, ['init']) const result = await listGitRepos(workspace) assert.deepEqual(result.repos.map((repo) => repo.name), ['business-project']) }) test('sensitive path rules cover credentials and private keys', () => { for (const value of ['.env', '.env.local', 'cert.pem', 'private.key', 'key.p12', 'id_ed25519', 'id_rsa.backup', 'myCredentials.json', 'api-token.txt']) { assert.equal(isSensitivePath(value), true, value) } assert.equal(isSensitivePath('src/tokenizer.ts'), false) assert.equal(isSensitivePath('src/config.ts'), false) }) test('fair truncation keeps metadata and shares the 200KB budget', () => { const entries = [ { path: 'a', diff: 'a'.repeat(300_000), truncated: false }, { path: 'b', diff: 'b'.repeat(300_000), truncated: false }, { path: 'c', diff: 'c'.repeat(300_000), truncated: false }, ] const result = fairlyTruncateDiffs(entries) assert.ok(result.diffBytes <= MAX_COMMIT_DIFF_BYTES) assert.equal(result.truncated, true) const sizes = entries.map((entry) => Buffer.byteLength(entry.diff, 'utf8')) assert.ok(Math.max(...sizes) - Math.min(...sizes) <= 1) assert.ok(entries.every((entry) => entry.truncated)) }) test('commit input filters sensitive contents and caps diff bytes', async (t) => { const repo = initRepo(t) fs.writeFileSync(path.join(repo, '.env.local'), 'TOP_SECRET=do-not-upload\n') fs.writeFileSync(path.join(repo, 'large-a.txt'), 'a'.repeat(180_000)) fs.writeFileSync(path.join(repo, 'large-b.txt'), 'b'.repeat(180_000)) const input = await buildCommitInput(repo) assert.equal(input.files.length, 3) assert.ok(input.diffBytes <= MAX_COMMIT_DIFF_BYTES) assert.equal(input.truncated, true) const sensitive = input.files.find((file) => file.path === '.env.local') assert.equal(sensitive.sensitive, true) assert.equal(sensitive.diff, undefined) assert.equal(JSON.stringify(input).includes('do-not-upload'), false) assert.ok(input.files.filter((file) => file.diff).every((file) => file.truncated)) }) test('renaming a sensitive file to a normal path never exposes its diff', async (t) => { const repo = initRepo(t) fs.writeFileSync(path.join(repo, '.env'), 'SECRET=never-upload\n') git(repo, ['add', '-A']) git(repo, ['commit', '-m', 'add secret']) git(repo, ['mv', '.env', 'config.txt']) const input = await buildCommitInput(repo) const renamed = input.files.find((file) => file.path === 'config.txt') assert.equal(renamed.sensitive, true) assert.equal(renamed.diff, undefined) assert.equal(JSON.stringify(input).includes('never-upload'), false) }) test('status reports remotes, upstream and detached head', async (t) => { const repo = initRepo(t) const remote = createBareRemote(t) git(repo, ['remote', 'add', 'origin', remote]) git(repo, ['push', '-u', 'origin', 'main']) let state = await getRemoteState(repo) assert.deepEqual(state.remotes, ['origin']) assert.equal(state.upstream, 'origin/main') assert.equal(state.detachedHead, false) git(repo, ['checkout', '--detach']) state = await getRemoteState(repo) assert.equal(state.detachedHead, true) }) function cloneRepo(t, remote) { const repo = tempDir(t) git(repo, ['clone', remote, '.']) git(repo, ['config', 'user.name', 'Test User']) git(repo, ['config', 'user.email', 'test@example.com']) return repo } function commitFile(repo, fileName, content, message) { fs.writeFileSync(path.join(repo, fileName), content) git(repo, ['add', '-A']) git(repo, ['commit', '-m', message]) } function setupTrackedRemote(t) { const remote = createBareRemote(t) const seed = initRepo(t) git(seed, ['remote', 'add', 'origin', remote]) git(seed, ['push', '-u', 'origin', 'main']) git(remote, ['symbolic-ref', 'HEAD', 'refs/heads/main']) return { remote, seed, local: cloneRepo(t, remote) } } test('local status stays fast and marks remote state unconfirmed', async (t) => { const { local } = setupTrackedRemote(t) const status = await getGitStatus(local) assert.equal(status.hasHead, true) assert.equal(status.remoteStatusAvailable, false) assert.equal(status.aheadCount, undefined) assert.equal(status.behindCount, undefined) }) test('refresh reports synchronized upstream', async (t) => { const { local } = setupTrackedRemote(t) const status = await refreshGitStatus(local) assert.equal(status.remoteStatusAvailable, true, status.remoteStatusError) assert.equal(status.aheadCount, 0) assert.equal(status.behindCount, 0) }) test('refresh reports commits ahead of upstream', async (t) => { const { local } = setupTrackedRemote(t) commitFile(local, 'local.txt', 'local\n', 'local update') const status = await refreshGitStatus(local) assert.equal(status.remoteStatusAvailable, true, status.remoteStatusError) assert.equal(status.aheadCount, 1) assert.equal(status.behindCount, 0) }) test('refresh fetches and reports commits behind upstream', async (t) => { const { seed, local } = setupTrackedRemote(t) commitFile(seed, 'remote.txt', 'remote\n', 'remote update') git(seed, ['push']) const status = await refreshGitStatus(local) assert.equal(status.remoteStatusAvailable, true, status.remoteStatusError) assert.equal(status.aheadCount, 0) assert.equal(status.behindCount, 1) }) test('refresh reports diverged upstream', async (t) => { const { seed, local } = setupTrackedRemote(t) commitFile(local, 'local.txt', 'local\n', 'local update') commitFile(seed, 'remote.txt', 'remote\n', 'remote update') git(seed, ['push']) const status = await refreshGitStatus(local) assert.equal(status.remoteStatusAvailable, true, status.remoteStatusError) assert.equal(status.aheadCount, 1) assert.equal(status.behindCount, 1) }) test('refresh fetch failure preserves local status and redacts remote credentials', async (t) => { const repo = initRepo(t) git(repo, ['remote', 'add', 'origin', 'https://alice:super-secret@127.0.0.1:1/private/repo.git']) git(repo, ['update-ref', 'refs/remotes/origin/main', 'HEAD']) git(repo, ['branch', '--set-upstream-to=origin/main', 'main']) const status = await refreshGitStatus(repo) assert.equal(status.isGit, true) assert.equal(status.hasHead, true) assert.equal(status.remoteStatusAvailable, false) assert.ok(status.remoteStatusError) assert.equal(status.remoteStatusError.includes('super-secret'), false) }) test('commit stages all changes, skips hooks and injects identity only for the command', async (t) => { const repo = initRepo(t, false) try { git(repo, ['config', '--unset-all', 'user.name']) } catch {} try { git(repo, ['config', '--unset-all', 'user.email']) } catch {} fs.mkdirSync(path.join(repo, '.git', 'hooks'), { recursive: true }) const hook = path.join(repo, '.git', 'hooks', 'pre-commit') fs.writeFileSync(hook, '#!/bin/sh\nexit 1\n', { mode: 0o755 }) fs.writeFileSync(path.join(repo, 'new.txt'), 'new\n') const result = await commit(repo, undefined, 'feat(git): 增加本地提交\n\n- 提交全部变更') assert.equal(result.success, true, result.error) assert.equal(git(repo, ['show', '-s', '--format=%an <%ae>']), 'Mao Agent ') assert.throws(() => git(repo, ['config', '--get', 'user.name'])) assert.throws(() => git(repo, ['config', '--get', 'user.email'])) assert.equal(git(repo, ['status', '--porcelain']), '') }) // 中央 Tab 两步提交:用户消息不再限定 Conventional Commits 格式,原样落库(单个 -m,git 不用空行拼接) test('commit accepts free-form message verbatim without format validation', async (t) => { const repo = initRepo(t) fs.writeFileSync(path.join(repo, 'note.txt'), 'note\n') const message = '随手一改\n第二段:没有空行分隔\n也没有列表' const result = await commit(repo, undefined, message) assert.equal(result.success, true, result.error) assert.equal(git(repo, ['log', '-1', '--pretty=%B']).trim(), message) assert.equal(result.commitTitle, '随手一改') }) test('commit rejects blank message', async (t) => { const repo = initRepo(t) fs.writeFileSync(path.join(repo, 'note.txt'), 'note\n') const result = await commit(repo, undefined, ' ') assert.equal(result.success, false) assert.match(result.error, /提交信息不能为空/) }) test('commit failure keeps add -A staging', async (t) => { const repo = initRepo(t) fs.writeFileSync(path.join(repo, 'file.txt'), 'changed\n') fs.writeFileSync(path.join(repo, '.git', 'COMMIT_EDITMSG'), '') git(repo, ['config', 'commit.gpgsign', 'true']) git(repo, ['config', 'user.signingkey', 'missing-key']) const result = await commit(repo, undefined, 'fix(git): 验证失败保留暂存\n\n- 保留已暂存文件') assert.equal(result.success, false) assert.match(git(repo, ['diff', '--cached', '--name-only']), /file\.txt/) }) test('dirty pull stashes tracked and untracked then restores exact stash', async (t) => { const remote = createBareRemote(t) const seed = initRepo(t) git(seed, ['remote', 'add', 'origin', remote]) git(seed, ['push', '-u', 'origin', 'main']) git(remote, ['symbolic-ref', 'HEAD', 'refs/heads/main']) const local = tempDir(t) git(local, ['clone', remote, '.']) git(local, ['config', 'user.name', 'Local']) git(local, ['config', 'user.email', 'local@example.com']) fs.writeFileSync(path.join(local, 'README.md'), 'local dirty\n') fs.writeFileSync(path.join(local, 'untracked.txt'), 'untracked\n') fs.writeFileSync(path.join(seed, 'remote.txt'), 'remote\n') git(seed, ['add', '-A']) git(seed, ['commit', '-m', 'remote update']) git(seed, ['push']) const result = await pull(local) assert.equal(result.success, true, result.error) assert.equal(fs.readFileSync(path.join(local, 'README.md'), 'utf8'), 'local dirty\n') assert.equal(fs.readFileSync(path.join(local, 'untracked.txt'), 'utf8'), 'untracked\n') assert.equal(fs.readFileSync(path.join(local, 'remote.txt'), 'utf8'), 'remote\n') assert.equal(git(local, ['stash', 'list']), '') }) test('push sets origin upstream and never needs force', async (t) => { const repo = initRepo(t) const remote = createBareRemote(t) git(repo, ['remote', 'add', 'origin', remote]) const result = await push(repo) assert.equal(result.success, true, result.error) assert.equal(git(repo, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}']), 'origin/main') }) test('switch target resolution covers local, remote and invalid names', () => { assert.deepEqual(resolveSwitchTarget('main', false, ['origin']), { args: ['switch', 'main'], localBranch: 'main' }) assert.deepEqual(resolveSwitchTarget('origin/side', false, ['origin']), { args: ['switch', '-c', 'side', '--track', 'origin/side'], localBranch: 'side', }) assert.deepEqual(resolveSwitchTarget('origin/side', true, ['origin']), { args: ['switch', '-c', 'side', '--track', 'origin/side'], localBranch: 'side', }) // 未知前缀不是 remote 引用,按本地分支处理(让 git 报分支不存在,而不是误判成远端) assert.deepEqual(resolveSwitchTarget('feature/x', false, ['origin']), { args: ['switch', 'feature/x'], localBranch: 'feature/x' }) for (const invalid of ['', ' ', '..', 'a..b', 'a@{1}', '@', '/a', 'a/', 'a//b', 'a.', '.hidden', 'x.lock', 'a b']) { assert.throws(() => resolveSwitchTarget(invalid, false, ['origin']), /分支名无效/, invalid) } // create=true 且为普通分支名:基于当前 HEAD 新建本地分支(git switch -c ) assert.deepEqual(resolveSwitchTarget('side', true, []), { args: ['switch', '-c', 'side'], localBranch: 'side' }) // 本地新建路径的名字直接拼进 argv,首字符 `-` 会被 git 当 option 解析,显式拒绝 assert.throws(() => resolveSwitchTarget('-x', true, []), /分支名无效/) }) test('fetch prunes the remote without touching the worktree', async (t) => { const { remote, seed, local } = setupTrackedRemote(t) commitFile(seed, 'remote.txt', 'remote\n', 'remote update') git(seed, ['push']) git(seed, ['branch', 'gone-branch']) git(seed, ['push', 'origin', 'gone-branch']) git(local, ['fetch', 'origin']) assert.ok(git(local, ['branch', '-r']).includes('origin/gone-branch')) git(seed, ['push', 'origin', '--delete', 'gone-branch']) const result = await fetch(local) assert.equal(result.success, true, result.error) assert.equal(result.operation, 'fetch') assert.equal(result.branch, 'main') assert.equal(git(local, ['status', '--porcelain']), '') assert.equal(git(local, ['branch', '-r']).includes('origin/gone-branch'), false) const status = await refreshGitStatus(local) assert.equal(status.behindCount, 1) }) test('fetch fails without a remote and fetches every remote otherwise', async (t) => { const repo = initRepo(t) const noRemote = await fetch(repo) assert.equal(noRemote.success, false) assert.match(noRemote.error, /未配置远端/) // 多个远端(含非 origin):全部获取,不再判定歧义 const upstream = createBareRemote(t) const mirror = createBareRemote(t) git(repo, ['remote', 'add', 'upstream', upstream]) git(repo, ['remote', 'add', 'mirror', mirror]) git(repo, ['push', '-q', 'upstream', 'main']) git(repo, ['push', '-q', 'mirror', 'main']) // 两个待获取的推进:upstream 一个提交、mirror 另一个提交(从同一克隆分别推送) const seed = tempDir(t) git(seed, ['clone', '-q', '-b', 'main', upstream, 'work']) const work = path.join(seed, 'work') git(work, ['config', 'user.name', 'Other']) git(work, ['config', 'user.email', 'other@example.com']) fs.writeFileSync(path.join(work, 'upstream.txt'), 'upstream\n') git(work, ['add', '-A']) git(work, ['commit', '-m', 'advance upstream']) git(work, ['push', '-q', 'origin', 'main']) fs.writeFileSync(path.join(work, 'mirror.txt'), 'mirror\n') git(work, ['add', '-A']) git(work, ['commit', '-m', 'advance mirror']) git(work, ['push', '-q', mirror, 'main']) const result = await fetch(repo) assert.equal(result.success, true, result.error) assert.equal(result.message, '远端已更新') // 两个远端跟踪引用都更新:全部远端(含非 origin)都执行了 fetch assert.equal(git(repo, ['rev-parse', 'upstream/main']), git(upstream, ['rev-parse', 'main'])) assert.equal(git(repo, ['rev-parse', 'mirror/main']), git(mirror, ['rev-parse', 'main'])) }) test('fetch reports partial failure when one remote is unreachable', async (t) => { const repo = initRepo(t) const good = createBareRemote(t) git(repo, ['remote', 'add', 'good', good]) git(repo, ['push', '-q', 'good', 'main']) git(repo, ['remote', 'add', 'broken', 'http://user:secret@127.0.0.1:1/repo.git']) const result = await fetch(repo) assert.equal(result.success, true, result.error) assert.match(result.message, /部分远端已更新/) assert.match(result.message, /broken/) assert.equal(result.message.includes('user:secret'), false) }) test('fetch reports a single hanging remote with the timeout message', async (t) => { const repo = initRepo(t) git(repo, ['remote', 'add', 'only-hang', await startHangingRemote(t)]) const result = await withGitTimeout(2000, () => fetch(repo)) assert.equal(result.success, false) assert.equal(result.error, 'Git 操作超过 2 秒,已终止') }) test('fetch names every remote when all remotes fail', async (t) => { const repo = initRepo(t) git(repo, ['remote', 'add', 'alpha', 'http://127.0.0.1:1/alpha.git']) git(repo, ['remote', 'add', 'beta', 'http://127.0.0.1:1/beta.git']) const result = await fetch(repo) assert.equal(result.success, false) assert.equal(result.error, '全部远端获取失败:alpha、beta') }) test('fetch fetches hanging remotes in parallel within a single timeout budget', async (t) => { const repo = initRepo(t) const url = await startHangingRemote(t) for (const name of ['h1', 'h2', 'h3']) git(repo, ['remote', 'add', name, url]) const started = Date.now() const result = await withGitTimeout(2000, () => fetch(repo)) const elapsed = Date.now() - started // 3 个挂起远端并行获取:总等待 ≍ 单个远端预算(串行会叠成 ~3 倍) assert.ok(elapsed < 4000, `elapsed=${elapsed}ms`) assert.equal(result.success, false) assert.equal(result.error, '全部远端获取失败:h1、h2、h3') }) test('switch branch stashes local changes and restores them exactly', async (t) => { const repo = initRepo(t) git(repo, ['checkout', '-b', 'feature']) commitFile(repo, 'feature.txt', 'feature\n', 'feat: feature file') git(repo, ['checkout', 'main']) fs.writeFileSync(path.join(repo, 'README.md'), 'local dirty\n') fs.writeFileSync(path.join(repo, 'untracked.txt'), 'untracked\n') const result = await switchBranch(repo, undefined, 'feature') assert.equal(result.success, true, result.error) assert.equal(result.operation, 'switch') assert.equal(result.branch, 'feature') assert.equal(fs.readFileSync(path.join(repo, 'README.md'), 'utf8'), 'local dirty\n') assert.equal(fs.readFileSync(path.join(repo, 'untracked.txt'), 'utf8'), 'untracked\n') assert.equal(fs.existsSync(path.join(repo, 'feature.txt')), true) // 自动 stash 已恢复,stash 栈不留残留 assert.equal(git(repo, ['stash', 'list']), '') }) test('switch branch creates a tracking branch from a remote ref', async (t) => { const { seed, local } = setupTrackedRemote(t) git(seed, ['checkout', '-b', 'side']) commitFile(seed, 'side.txt', 'side\n', 'feat: side file') git(seed, ['push', '-u', 'origin', 'side']) git(local, ['fetch', 'origin']) const result = await switchBranch(local, undefined, 'origin/side') assert.equal(result.success, true, result.error) assert.equal(result.branch, 'side') assert.equal(git(local, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}']), 'origin/side') assert.equal(fs.readFileSync(path.join(local, 'side.txt'), 'utf8'), 'side\n') }) test('switch branch refuses detached head and invalid names', async (t) => { const repo = initRepo(t) git(repo, ['checkout', '--detach']) const detached = await switchBranch(repo, undefined, 'main') assert.equal(detached.success, false) assert.match(detached.error, /detached HEAD/) const fresh = initRepo(t) const invalid = await switchBranch(fresh, undefined, 'bad..name') assert.equal(invalid.success, false) assert.match(invalid.error, /分支名无效/) assert.equal(git(fresh, ['rev-parse', '--abbrev-ref', 'HEAD']), 'main') }) test('stash apply pop drop round trip', async (t) => { const repo = initRepo(t) fs.writeFileSync(path.join(repo, 'README.md'), 'stashed\n') git(repo, ['stash', 'push', '-m', 'wip']) const applied = await stashOp(repo, undefined, 'apply', 'stash@{0}') assert.equal(applied.success, true, applied.error) assert.equal(applied.stashRef, 'stash@{0}') assert.equal(fs.readFileSync(path.join(repo, 'README.md'), 'utf8'), 'stashed\n') assert.equal(git(repo, ['stash', 'list']).includes('wip'), true) git(repo, ['checkout', '--', 'README.md']) const popped = await stashOp(repo, undefined, 'pop', 'stash@{0}') assert.equal(popped.success, true, popped.error) assert.equal(fs.readFileSync(path.join(repo, 'README.md'), 'utf8'), 'stashed\n') assert.equal(git(repo, ['stash', 'list']), '') fs.writeFileSync(path.join(repo, 'README.md'), 'again\n') git(repo, ['stash', 'push', '-m', 'wip2']) const dropped = await stashOp(repo, undefined, 'drop', 'stash@{0}') assert.equal(dropped.success, true, dropped.error) assert.equal(git(repo, ['stash', 'list']), '') assert.equal(fs.readFileSync(path.join(repo, 'README.md'), 'utf8'), 'initial\n') }) test('stash op apply conflict reports conflict and keeps the scene', async (t) => { const repo = initRepo(t) fs.writeFileSync(path.join(repo, 'README.md'), 'stashed change\n') git(repo, ['stash', 'push', '-m', 'wip conflict']) fs.writeFileSync(path.join(repo, 'README.md'), 'head change\n') git(repo, ['add', '-A']) git(repo, ['commit', '-m', 'diverge README']) // git stash apply 冲突时退出码为 1:必须走冲突契约(conflict + 保留现场引导), // 而不是裸 git 错误——否则契约里的 conflict/stashRef 全丢 const applied = await stashOp(repo, undefined, 'apply', 'stash@{0}') assert.equal(applied.success, false) assert.equal(applied.conflict, true) assert.equal(applied.stashRef, 'stash@{0}') assert.match(applied.error, /已保留现场/) assert.match(applied.error, /stash@\{0\}/) // 现场保留:stash 条目仍在,工作区留下冲突标记 assert.match(git(repo, ['stash', 'list']), /wip conflict/) assert.match(git(repo, ['status', '--porcelain']), /UU README\.md/) }) test('stash op rejects unknown actions and bad refs', async (t) => { const repo = initRepo(t) fs.writeFileSync(path.join(repo, 'README.md'), 'dirty\n') git(repo, ['stash', 'push', '-m', 'wip']) for (const [action, ref] of [['delete', 'stash@{0}'], ['apply', 'not-a-ref'], ['pop', 'stash@{99}']]) { const result = await stashOp(repo, undefined, action, ref) assert.equal(result.success, false, `${action} ${ref}`) } // 校验失败不动 stash 栈 assert.equal(git(repo, ['stash', 'list']).includes('wip'), true) })