Ctrl K
ComunidadGuías…

desktop/electron/gitStatus.test.cjs

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

Archivo original ↗Preguntar a KiBot
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 {
  getGitBranches,
  getGitBranchCompare,
  getGitBranchCompareFileDiff,
  getGitCommitFileDiff,
  getGitCommitFiles,
  getGitLog,
  getGitRemotes,
  getGitStashes,
  getGitStatus,
  isValidBranchName,
  numstatNewPath,
  parseBranchVvLine,
  parseLogLine,
  refreshGitStatus,
  resolveRepoDir,
  resolveRepoRoot,
} = require('./gitStatus.cjs')

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-status-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 commitFile(repo, fileName, content, message) {
  fs.writeFileSync(path.join(repo, fileName), content)
  git(repo, ['add', '-A'])
  git(repo, ['commit', '-m', message])
}

function createBareRemote(t) {
  const dir = tempDir(t)
  git(dir, ['init', '--bare'])
  return dir
}

/** 启动一个「接受连接但永不响应」的本地 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('log parses commits with refs, clamps limit and honours path filter', async (t) => {
  const repo = initRepo(t)
  commitFile(repo, 'a.txt', 'a\n', 'feat: add a')
  fs.mkdirSync(path.join(repo, 'sub'))
  commitFile(repo, 'sub/b.txt', 'b\n', 'feat: add b')
  commitFile(repo, 'a.txt', 'a2\n', 'fix: touch a again')
  const head = git(repo, ['rev-parse', 'HEAD'])

  const entries = await getGitLog(repo)
  assert.equal(entries.length, 4)
  assert.equal(entries[0].hash, head)
  assert.equal(entries[0].title, 'fix: touch a again')
  assert.equal(entries[0].author, 'Test User')
  assert.ok(entries[0].date.includes('T'))
  assert.match(entries[0].refs, /HEAD -> main/)

  git(repo, ['tag', 'v1'])
  const tagged = await getGitLog(repo, undefined, { limit: 1 })
  assert.equal(tagged.length, 1)
  assert.match(tagged[0].refs, /tag: v1/)

  // limit 越界钳制到 [1,200] 下界:0 → 1
  const clamped = await getGitLog(repo, undefined, { limit: 0 })
  assert.equal(clamped.length, 1)

  const filtered = await getGitLog(repo, undefined, { path: 'sub/b.txt' })
  assert.deepEqual(filtered.map((entry) => entry.title), ['feat: add b'])

  const paged = await getGitLog(repo, undefined, { limit: 1, offset: 1 })
  assert.equal(paged.length, 1)
  assert.equal(paged[0].title, 'feat: add b')
  const deeper = await getGitLog(repo, undefined, { limit: 1, offset: 2 })
  assert.equal(deeper[0].title, 'feat: add a')
})

test('log on unborn repository returns empty list', async (t) => {
  const dir = tempDir(t)
  git(dir, ['init', '-b', 'main'])
  assert.deepEqual(await getGitLog(dir), [])
})

test('log resolves the repo root of a first-level repoPath', async (t) => {
  const workspace = tempDir(t)
  const repo = path.join(workspace, 'inner')
  fs.mkdirSync(repo)
  git(repo, ['init', '-b', 'main'])
  git(repo, ['config', 'user.name', 'Test User'])
  git(repo, ['config', 'user.email', 'test@example.com'])
  commitFile(repo, 'a.txt', 'a\n', 'feat: inner commit')
  const entries = await getGitLog(workspace, 'inner', undefined)
  assert.equal(entries.length, 1)
  assert.equal(entries[0].title, 'feat: inner commit')
})

test('log line parser tolerates empty decorations', () => {
  const line = ['abc1234', 'abc1234', 'subject', 'Test User', '2026-10-28T10:00:00+08:00', 'HEAD -> main'].join('\u0000')
  const entry = parseLogLine(line)
  assert.deepEqual(entry, {
    hash: 'abc1234',
    shortHash: 'abc1234',
    title: 'subject',
    author: 'Test User',
    date: '2026-10-28T10:00:00+08:00',
    refs: 'HEAD -> main',
  })
  const bare = parseLogLine(['abc1234', 'abc1234', 'subject', 'Test User', '2026-10-28T10:00:00+08:00', ''].join('\u0000'))
  assert.equal(bare.refs, undefined)
  assert.equal(parseLogLine('broken'), null)
})

test('commit files merge numstat for a same-directory rename and reject unknown hashes', async (t) => {
  const repo = initRepo(t)
  fs.mkdirSync(path.join(repo, 'src'))
  fs.writeFileSync(path.join(repo, 'src', 'a.txt'), 'one\ntwo\n')
  git(repo, ['add', '-A'])
  git(repo, ['commit', '-m', 'add src/a.txt'])
  git(repo, ['mv', 'src/a.txt', 'src/b.txt'])
  fs.writeFileSync(path.join(repo, 'src', 'b.txt'), 'one\ntwo\nthree\n')
  git(repo, ['add', '-A'])
  git(repo, ['commit', '-m', 'rename and edit'])
  const hash = git(repo, ['rev-parse', 'HEAD'])

  const { hash: resultHash, files } = await getGitCommitFiles(repo, undefined, hash)
  assert.equal(resultHash, hash)
  assert.equal(files.length, 1)
  assert.equal(files[0].path, 'src/b.txt')
  assert.equal(files[0].oldPath, 'src/a.txt')
  assert.equal(files[0].changeType, 'RENAMED')
  assert.equal(files[0].insertions, 1)
  assert.equal(files[0].deletions, 0)

  await assert.rejects(() => getGitCommitFiles(repo, undefined, 'deadbeef'), /提交不存在/)
  await assert.rejects(() => getGitCommitFiles(repo, undefined, 'nope'), /提交不存在/)
})

test('commit files use the first-parent diff of a merge commit', async (t) => {
  const repo = initRepo(t)
  git(repo, ['checkout', '-b', 'side'])
  commitFile(repo, 'side.txt', 'side\n', 'feat: side file')
  git(repo, ['checkout', 'main'])
  git(repo, ['merge', '--no-ff', 'side', '-m', 'merge side'])
  const hash = git(repo, ['rev-parse', 'HEAD'])
  const { files } = await getGitCommitFiles(repo, undefined, hash)
  assert.deepEqual(files.map((file) => file.path), ['side.txt'])
  assert.equal(files[0].changeType, 'CREATED')
})

test('commit file diff covers modified, created and deleted files', async (t) => {
  const repo = initRepo(t)
  const rootHash = git(repo, ['rev-parse', 'HEAD'])
  const created = await getGitCommitFileDiff(repo, undefined, rootHash, 'README.md')
  assert.equal(created.changeType, 'CREATED')
  assert.equal(created.beforeContent, '')
  assert.equal(created.afterContent, 'initial\n')

  commitFile(repo, 'gone.txt', 'gone\n', 'add gone.txt')
  git(repo, ['rm', '-q', 'gone.txt'])
  git(repo, ['commit', '-m', 'remove gone.txt'])
  const removeHash = git(repo, ['rev-parse', 'HEAD'])
  const deleted = await getGitCommitFileDiff(repo, undefined, removeHash, 'gone.txt')
  assert.equal(deleted.changeType, 'DELETED')
  assert.equal(deleted.beforeContent, 'gone\n')
  assert.equal(deleted.afterContent, '')

  commitFile(repo, 'README.md', 'changed\n', 'modify README')
  const modHash = git(repo, ['rev-parse', 'HEAD'])
  const modified = await getGitCommitFileDiff(repo, undefined, modHash, 'README.md')
  assert.equal(modified.changeType, 'MODIFIED')
  assert.equal(modified.beforeContent, 'initial\n')
  assert.equal(modified.afterContent, 'changed\n')
})

test('commit file diff reports invalid hash and path escapes', async (t) => {
  const repo = initRepo(t)
  const badHash = await getGitCommitFileDiff(repo, undefined, 'not-a-hash', 'README.md')
  assert.equal(badHash.unavailableReason, '提交不存在')
  const escape = await getGitCommitFileDiff(repo, undefined, git(repo, ['rev-parse', 'HEAD']), '../secrets.txt')
  assert.equal(escape.unavailableReason, '路径无效')
})

test('commit file diff reads renamed file from its old path', async (t) => {
  const repo = initRepo(t)
  fs.mkdirSync(path.join(repo, 'src'))
  fs.writeFileSync(path.join(repo, 'src', 'a.txt'), 'old content\n')
  git(repo, ['add', '-A'])
  git(repo, ['commit', '-m', 'add src/a.txt'])
  git(repo, ['mv', 'src/a.txt', 'src/b.txt'])
  fs.writeFileSync(path.join(repo, 'src', 'b.txt'), 'new content\n')
  git(repo, ['add', '-A'])
  git(repo, ['commit', '-m', 'rename and edit'])
  const hash = git(repo, ['rev-parse', 'HEAD'])

  // 不传 oldPath:新路径在父提交不存在,before 取空、退化成 CREATED(旧缺陷)
  const naive = await getGitCommitFileDiff(repo, undefined, hash, 'src/b.txt')
  assert.equal(naive.changeType, 'CREATED')

  // 传 oldPath(文件清单 R 行的旧路径):before 取自父提交的旧路径,changeType 对齐「改名」
  const renamed = await getGitCommitFileDiff(repo, undefined, hash, 'src/b.txt', 'src/a.txt')
  assert.equal(renamed.changeType, 'RENAMED')
  assert.equal(renamed.beforeContent, 'old content\n')
  assert.equal(renamed.afterContent, 'new content\n')
})

test('workspace status merges numstat for a same-directory rename', async (t) => {
  const repo = initRepo(t)
  fs.mkdirSync(path.join(repo, 'src'))
  fs.writeFileSync(path.join(repo, 'src', 'a.txt'), 'one\ntwo\n')
  git(repo, ['add', '-A'])
  git(repo, ['commit', '-m', 'add src/a.txt'])
  git(repo, ['mv', 'src/a.txt', 'src/b.txt'])
  fs.writeFileSync(path.join(repo, 'src', 'b.txt'), 'one\ntwo\nthree\n')

  const status = await getGitStatus(repo)
  const renamed = status.files.find((file) => file.path === 'src/b.txt')
  assert.ok(renamed, JSON.stringify(status.files))
  assert.equal(renamed.changeType, 'RENAMED')
  assert.equal(renamed.oldPath, 'src/a.txt')
  // numstat 花括号紧凑形态(src/{a.txt => b.txt})必须按键合并:漏合并则改名恒显 +0/-0
  assert.equal(renamed.insertions, 1)
  assert.equal(renamed.deletions, 0)
})

test('read methods reject workspaces nested inside an outer repository', async (t) => {
  // 工作区本身位于外层 git 仓库内部:rev-parse --show-toplevel 会跳出工作区,
  // 只读方法必须拒绝(与写路径 resolveRepository 同口径),否则读到外层仓库元数据
  const outer = tempDir(t)
  git(outer, ['init', '-b', 'main'])
  git(outer, ['config', 'user.name', 'Test User'])
  git(outer, ['config', 'user.email', 'test@example.com'])
  fs.writeFileSync(path.join(outer, 'outside-secret.txt'), 'outer repo file outside workspace\n')
  git(outer, ['add', '-A'])
  git(outer, ['commit', '-m', 'outer init'])
  const hash = git(outer, ['rev-parse', 'HEAD'])
  const ws = path.join(outer, 'ws')
  fs.mkdirSync(ws)

  await assert.rejects(() => getGitLog(ws), /路径访问被拒绝/)
  await assert.rejects(() => getGitBranches(ws), /路径访问被拒绝/)
  await assert.rejects(() => getGitStashes(ws), /路径访问被拒绝/)
  await assert.rejects(() => getGitRemotes(ws), /路径访问被拒绝/)
  await assert.rejects(() => getGitCommitFiles(ws, undefined, hash), /路径访问被拒绝/)
  // 最关键:不能读到工作区之外的受控文件内容
  const leak = await getGitCommitFileDiff(ws, undefined, hash, 'outside-secret.txt')
  assert.equal(leak.unavailableReason, '路径访问被拒绝')
  assert.equal(leak.afterContent, '')
})

test('branches parse local and remote entries with ahead/behind', async (t) => {
  const remote = tempDir(t)
  git(remote, ['init', '--bare'])
  const seed = initRepo(t)
  git(seed, ['remote', 'add', 'origin', remote])
  git(seed, ['push', '-u', 'origin', 'main'])
  // bare 仓的 HEAD 默认落在 init.defaultBranch 上(CI runner 未配置时是 master),
  // 不显式指到 main 的话 clone 会拿到未出生分支,本地 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'])

  commitFile(local, 'local.txt', 'local\n', 'local commit')
  const branches = await getGitBranches(local)
  const main = branches.find((branch) => branch.name === 'main')
  assert.equal(main.isCurrent, true)
  assert.equal(main.isRemote, false)
  assert.equal(main.upstream, 'origin/main')
  assert.equal(main.ahead, 1)
  assert.equal(main.behind, undefined)
  assert.ok(main.lastCommitHash)
  assert.equal(main.lastCommitTitle, 'local commit')

  const originMain = branches.find((branch) => branch.name === 'origin/main')
  assert.equal(originMain.isRemote, true)
  assert.equal(originMain.isCurrent, false)

  // 远端被 prune 的跟踪态:upstream 名保留,ahead/behind 留空
  git(seed, ['branch', 'side'])
  git(seed, ['push', 'origin', 'side'])
  git(local, ['fetch', 'origin'])
  git(local, ['branch', '--track', 'side', 'origin/side'])
  git(remote, ['branch', '-D', 'side'])
  git(local, ['fetch', '--prune', 'origin'])
  const pruned = await getGitBranches(local)
  const side = pruned.find((branch) => branch.name === 'side')
  assert.equal(side.upstream, 'origin/side')
  assert.equal(side.ahead, undefined)
  assert.equal(side.behind, undefined)
})

test('branches report the unborn branch on an empty repository', async (t) => {
  const dir = tempDir(t)
  git(dir, ['init', '-b', 'main'])
  const branches = await getGitBranches(dir)
  assert.equal(branches.length, 1)
  assert.equal(branches[0].name, 'main')
  assert.equal(branches[0].isCurrent, true)
  assert.equal(branches[0].isRemote, false)
  assert.equal(branches[0].lastCommitHash, undefined)
})

test('branches skip the detached HEAD pseudo entry', async (t) => {
  const repo = initRepo(t)
  git(repo, ['checkout', '--detach'])
  const branches = await getGitBranches(repo)
  assert.deepEqual(branches.map((branch) => branch.name), ['main'])
  assert.equal(branches[0].isCurrent, false)
})

test('branch line parser keeps the gone upstream and ignores detached rows', () => {
  const ahead = parseBranchVvLine('* main abc1234 [origin/main: ahead 1, behind 2] subject line')
  assert.deepEqual(ahead, {
    name: 'main',
    isCurrent: true,
    upstream: 'origin/main',
    ahead: 1,
    behind: 2,
    lastCommitHash: 'abc1234',
    lastCommitTitle: 'subject line',
  })
  const gone = parseBranchVvLine('  side def5678 [origin/side: gone] side subject')
  assert.equal(gone.name, 'side')
  assert.equal(gone.isCurrent, false)
  assert.equal(gone.upstream, 'origin/side')
  assert.equal(gone.ahead, undefined)
  assert.equal(gone.behind, undefined)
  assert.equal(gone.lastCommitTitle, 'side subject')
  const plain = parseBranchVvLine('  topic 1234567 plain subject')
  assert.deepEqual(plain, {
    name: 'topic',
    isCurrent: false,
    upstream: undefined,
    ahead: undefined,
    behind: undefined,
    lastCommitHash: '1234567',
    lastCommitTitle: 'plain subject',
  })
  const detached = parseBranchVvLine('  (HEAD detached at abc1234) subject')
  assert.equal(detached.name, '(HEAD')
  assert.equal(parseBranchVvLine(''), null)
})

test('numstat new path restores brace renames and plain renames', () => {
  assert.equal(numstatNewPath('src/{a.txt => b.txt}'), 'src/b.txt')
  assert.equal(numstatNewPath('src/{a.txt => sub/b.txt}'), 'src/sub/b.txt')
  assert.equal(numstatNewPath('dir => dir2/x.txt'), 'dir2/x.txt')
  assert.equal(numstatNewPath('plain.txt'), 'plain.txt')
  assert.equal(numstatNewPath(''), '')
})

test('stashes list refs, messages and dates', async (t) => {
  const repo = initRepo(t)
  assert.deepEqual(await getGitStashes(repo), [])
  fs.writeFileSync(path.join(repo, 'README.md'), 'dirty\n')
  git(repo, ['stash', 'push', '-m', 'wip: dirty readme'])
  const stashes = await getGitStashes(repo)
  assert.equal(stashes.length, 1)
  assert.equal(stashes[0].ref, 'stash@{0}')
  assert.ok(stashes[0].message.includes('wip: dirty readme'))
  assert.ok(stashes[0].date.includes('T'))
})

test('remotes merge fetch and push urls and redact credentials', async (t) => {
  const repo = initRepo(t)
  assert.deepEqual(await getGitRemotes(repo), [])
  git(repo, ['remote', 'add', 'origin', 'https://alice:super-secret@example.com/a/b.git'])
  const remotes = await getGitRemotes(repo)
  assert.equal(remotes.length, 1)
  assert.equal(remotes[0].name, 'origin')
  assert.equal(remotes[0].fetchUrl.includes('super-secret'), false)
  assert.equal(remotes[0].pushUrl.includes('super-secret'), false)
  assert.equal(remotes[0].fetchUrl, remotes[0].pushUrl)
})

test('repo resolution rejects non-git workspaces and escaping repo paths', async (t) => {
  const plain = tempDir(t)
  await assert.rejects(() => resolveRepoRoot(plain), /不是 Git 仓库/)
  await assert.rejects(() => resolveRepoRoot(''), /工作区无效/)
  const workspace = tempDir(t)
  const repo = path.join(workspace, 'inner')
  fs.mkdirSync(repo)
  git(repo, ['init', '-b', 'main'])
  assert.equal(await resolveRepoRoot(workspace, 'inner'), fs.realpathSync(repo))
  assert.throws(() => resolveRepoDir(workspace, '../escape'), /路径访问被拒绝/)
  assert.throws(() => resolveRepoDir(workspace, 'inner/deeper'), /路径访问被拒绝/)
  assert.throws(() => resolveRepoDir(workspace, 'missing'), /路径访问被拒绝/)
})

test('branch compare counts diverged commits and lists both sides of the two-point diff', async (t) => {
  const repo = initRepo(t)
  git(repo, ['checkout', '-b', 'feature'])
  commitFile(repo, 'f1.txt', 'one\n', 'feat: add f1')
  commitFile(repo, 'f2.txt', 'two\n', 'feat: add f2')
  git(repo, ['checkout', 'main'])
  commitFile(repo, 'main-only.txt', 'main\n', 'feat: main only')

  const result = await getGitBranchCompare(repo, undefined, 'main', 'feature')
  assert.equal(result.base, 'main')
  assert.equal(result.target, 'feature')
  // 计数仍是 rev-list --left-right --count main...feature 的 right = 目标独有(领先 2),left = 基准独有(落后 1)
  assert.equal(result.aheadCount, 2)
  assert.equal(result.behindCount, 1)
  // 清单是两点 diff(两棵树直接比较):目标侧新增 A + 基准侧独有文件以反向删除 D 出现,两侧都要在
  const byPath = new Map(result.files.map((file) => [file.path, file]))
  assert.deepEqual([...byPath.keys()].sort(), ['f1.txt', 'f2.txt', 'main-only.txt'])
  assert.equal(byPath.get('f1.txt').changeType, 'CREATED')
  assert.equal(byPath.get('f2.txt').changeType, 'CREATED')
  assert.equal(byPath.get('main-only.txt').changeType, 'DELETED')
  assert.deepEqual([byPath.get('f1.txt').insertions, byPath.get('f1.txt').deletions], [1, 0])
  assert.deepEqual([byPath.get('main-only.txt').insertions, byPath.get('main-only.txt').deletions], [0, 1])
})

test('branch compare lists the base side when the target is fully behind', async (t) => {
  const repo = initRepo(t)
  const fork = git(repo, ['rev-parse', 'HEAD'])
  commitFile(repo, 'later.txt', 'later\n', 'feat: later')
  git(repo, ['branch', 'behind', fork])

  // 核心动机:target 完全落后时三点 diff 恒为空、用户看不到任何差异;
  // 两点 diff 必须列出基准侧的全部变更(基准有、目标无 → 反向删除)
  const behind = await getGitBranchCompare(repo, undefined, 'main', 'behind')
  assert.equal(behind.aheadCount, 0)
  assert.equal(behind.behindCount, 1)
  assert.deepEqual(behind.files.map((file) => [file.path, file.changeType]), [['later.txt', 'DELETED']])
  assert.deepEqual([behind.files[0].insertions, behind.files[0].deletions], [0, 1])

  // 方向反转(落后分支当基准):同一文件变回新增,计数与清单同时翻转
  const reversed = await getGitBranchCompare(repo, undefined, 'behind', 'main')
  assert.equal(reversed.aheadCount, 1)
  assert.equal(reversed.behindCount, 0)
  assert.deepEqual(reversed.files.map((file) => [file.path, file.changeType]), [['later.txt', 'CREATED']])
})

test('branch compare returns an empty list when both branches hold the same content', async (t) => {
  const repo = initRepo(t)
  commitFile(repo, 'later.txt', 'later\n', 'feat: later')
  git(repo, ['branch', 'twin'])

  // 两分支同一提交(不同分支名):diff 空输出、退出码 0 → 合法空列表
  const twin = await getGitBranchCompare(repo, undefined, 'main', 'twin')
  assert.equal(twin.aheadCount, 0)
  assert.equal(twin.behindCount, 0)
  assert.deepEqual(twin.files, [])

  // 同一分支自比同样为空列表;「执行失败」仍抛错(见 ref 不存在用例),两者不可混淆
  const same = await getGitBranchCompare(repo, undefined, 'main', 'main')
  assert.equal(same.aheadCount, 0)
  assert.equal(same.behindCount, 0)
  assert.deepEqual(same.files, [])
})

test('branch compare restores rename paths and numstat counts', async (t) => {
  const repo = initRepo(t)
  fs.mkdirSync(path.join(repo, 'src'))
  fs.writeFileSync(path.join(repo, 'src', 'a.txt'), 'one\ntwo\n')
  git(repo, ['add', '-A'])
  git(repo, ['commit', '-m', 'add src/a.txt'])
  git(repo, ['checkout', '-b', 'feature'])
  git(repo, ['mv', 'src/a.txt', 'src/b.txt'])
  fs.writeFileSync(path.join(repo, 'src', 'b.txt'), 'one\ntwo\nthree\n')
  git(repo, ['add', '-A'])
  git(repo, ['commit', '-m', 'rename and edit'])

  const result = await getGitBranchCompare(repo, undefined, 'main', 'feature')
  assert.equal(result.files.length, 1)
  assert.equal(result.files[0].path, 'src/b.txt')
  assert.equal(result.files[0].oldPath, 'src/a.txt')
  assert.equal(result.files[0].changeType, 'RENAMED')
  // numstat 花括号紧凑形态(src/{a.txt => b.txt})必须还原后按键合并,否则改名恒显 +0/-0
  assert.equal(result.files[0].insertions, 1)
  assert.equal(result.files[0].deletions, 0)
})

test('branch compare marks additions, deletions and binary files', async (t) => {
  const repo = initRepo(t)
  fs.writeFileSync(path.join(repo, 'gone.txt'), 'gone\n')
  fs.writeFileSync(path.join(repo, 'blob.bin'), Buffer.from([0x00, 0x01, 0x02, 0x00]))
  git(repo, ['add', '-A'])
  git(repo, ['commit', '-m', 'add gone.txt and blob.bin'])
  git(repo, ['checkout', '-b', 'feature'])
  commitFile(repo, 'added.txt', 'added\n', 'feat: add added.txt')
  git(repo, ['rm', '-q', 'gone.txt'])
  git(repo, ['commit', '-m', 'remove gone.txt'])
  fs.writeFileSync(path.join(repo, 'blob.bin'), Buffer.from([0x00, 0xff, 0xfe, 0x00]))
  git(repo, ['add', '-A'])
  git(repo, ['commit', '-m', 'modify blob.bin'])

  const result = await getGitBranchCompare(repo, undefined, 'main', 'feature')
  const byPath = new Map(result.files.map((file) => [file.path, file]))
  assert.equal(byPath.get('added.txt').changeType, 'CREATED')
  assert.equal(byPath.get('added.txt').insertions, 1)
  assert.equal(byPath.get('gone.txt').changeType, 'DELETED')
  assert.equal(byPath.get('gone.txt').deletions, 1)
  // numstat 的二进制是 `-\t-`:insertions/deletions 归零并打 binary
  assert.equal(byPath.get('blob.bin').changeType, 'MODIFIED')
  assert.equal(byPath.get('blob.bin').binary, true)
  assert.deepEqual([byPath.get('blob.bin').insertions, byPath.get('blob.bin').deletions], [0, 0])
})

test('branch compare rejects missing refs and invalid branch names', async (t) => {
  const repo = initRepo(t)
  commitFile(repo, 'a.txt', 'a\n', 'feat: add a')
  await assert.rejects(() => getGitBranchCompare(repo, undefined, 'main', 'nope'), /分支不存在/)
  await assert.rejects(() => getGitBranchCompare(repo, undefined, 'nope', 'main'), /分支不存在/)

  // 非法分支名:字符类首字符排除 '-'(`-x` / `--all` 会被 git 当选项),其余结构性规则同后端
  const invalid = ['-x', '--all', 'a..b', 'a~b', 'a^b', 'a:b', 'a@{b', '/a', 'a/', 'a//b', '.a', 'a.lock', '@', '', 'x'.repeat(201)]
  for (const name of invalid) {
    assert.equal(isValidBranchName(name), false, `isValidBranchName(${JSON.stringify(name)})`)
    await assert.rejects(() => getGitBranchCompare(repo, undefined, 'main', name), /分支不存在/, `reject ${JSON.stringify(name)}`)
  }
  // 真实分支名不得被误伤:斜杠、点、下划线、连字符(非首位)都合法
  commitFile(repo, 'b.txt', 'b\n', 'feat: add b')
  git(repo, ['branch', 'feature/x-1_2.3'])
  assert.equal(isValidBranchName('feature/x-1_2.3'), true)
  const result = await getGitBranchCompare(repo, undefined, 'main', 'feature/x-1_2.3')
  assert.deepEqual(result.files, [])
})

test('branch compare handles unrelated histories with the two-point diff', async (t) => {
  const repo = initRepo(t)
  git(repo, ['checkout', '--orphan', 'orphan'])
  git(repo, ['rm', '-rf', '.'])
  commitFile(repo, 'orphan.txt', 'orphan\n', 'orphan root')

  // 两点语义不需要共同祖先:rev-list 的集合运算与两棵树 diff 都正常,不再有 merge-base 判定步骤
  assert.equal(git(repo, ['rev-list', '--left-right', '--count', 'main...orphan']), '1\t1')
  const forward = await getGitBranchCompare(repo, undefined, 'main', 'orphan')
  assert.equal(forward.aheadCount, 1)
  assert.equal(forward.behindCount, 1)
  assert.deepEqual(
    forward.files.map((file) => [file.path, file.changeType]).sort(),
    [['README.md', 'DELETED'], ['orphan.txt', 'CREATED']],
  )

  const backward = await getGitBranchCompare(repo, undefined, 'orphan', 'main')
  assert.equal(backward.aheadCount, 1)
  assert.equal(backward.behindCount, 1)
  assert.deepEqual(
    backward.files.map((file) => [file.path, file.changeType]).sort(),
    [['README.md', 'CREATED'], ['orphan.txt', 'DELETED']],
  )
})

test('branch compare on an unborn repository rejects instead of reporting no differences', async (t) => {
  const dir = tempDir(t)
  git(dir, ['init', '-b', 'main'])
  await assert.rejects(() => getGitBranchCompare(dir, undefined, 'main', 'main'), /分支不存在/)
})

test('branch compare rejects workspaces nested inside an outer repository', async (t) => {
  // 只读方法必须拒绝跳出工作区(与 resolveRepoRoot 同口径),否则会拿外层仓库的分支做对比
  const outer = tempDir(t)
  git(outer, ['init', '-b', 'main'])
  git(outer, ['config', 'user.name', 'Test User'])
  git(outer, ['config', 'user.email', 'test@example.com'])
  fs.writeFileSync(path.join(outer, 'outside-secret.txt'), 'outer repo file outside workspace\n')
  git(outer, ['add', '-A'])
  git(outer, ['commit', '-m', 'outer init'])
  const ws = path.join(outer, 'ws')
  fs.mkdirSync(ws)

  await assert.rejects(() => getGitBranchCompare(ws, undefined, 'main', 'main'), /路径访问被拒绝/)
  const leak = await getGitBranchCompareFileDiff(ws, undefined, 'main', 'main', 'outside-secret.txt')
  assert.equal(leak.unavailableReason, '路径访问被拒绝')
  assert.equal(leak.afterContent, '')
})

test('branch compare file diff covers modified, created and deleted files', async (t) => {
  const repo = initRepo(t)
  commitFile(repo, 'gone.txt', 'gone\n', 'feat: add gone.txt')
  git(repo, ['checkout', '-b', 'feature'])
  fs.writeFileSync(path.join(repo, 'README.md'), 'changed\n')
  fs.writeFileSync(path.join(repo, 'added.txt'), 'added\n')
  git(repo, ['rm', '-q', 'gone.txt'])
  git(repo, ['add', '-A'])
  git(repo, ['commit', '-m', 'feat: shape feature'])

  const created = await getGitBranchCompareFileDiff(repo, undefined, 'main', 'feature', 'added.txt')
  assert.equal(created.path, 'added.txt')
  assert.equal(created.changeType, 'CREATED')
  assert.equal(created.beforeContent, '')
  assert.equal(created.afterContent, 'added\n')

  const deleted = await getGitBranchCompareFileDiff(repo, undefined, 'main', 'feature', 'gone.txt')
  assert.equal(deleted.changeType, 'DELETED')
  assert.equal(deleted.beforeContent, 'gone\n')
  assert.equal(deleted.afterContent, '')

  const modified = await getGitBranchCompareFileDiff(repo, undefined, 'main', 'feature', 'README.md')
  assert.equal(modified.changeType, 'MODIFIED')
  assert.equal(modified.beforeContent, 'initial\n')
  assert.equal(modified.afterContent, 'changed\n')
})

test('branch compare file diff reads renamed files from the base side old path', async (t) => {
  const repo = initRepo(t)
  fs.mkdirSync(path.join(repo, 'src'))
  fs.writeFileSync(path.join(repo, 'src', 'a.txt'), 'old content\n')
  git(repo, ['add', '-A'])
  git(repo, ['commit', '-m', 'add src/a.txt'])
  git(repo, ['checkout', '-b', 'feature'])
  git(repo, ['mv', 'src/a.txt', 'src/b.txt'])
  fs.writeFileSync(path.join(repo, 'src', 'b.txt'), 'new content\n')
  git(repo, ['add', '-A'])
  git(repo, ['commit', '-m', 'rename and edit'])

  // 不传 oldPath:新路径在 base 树里不存在,before 取空、退化成 CREATED(旧缺陷)
  const naive = await getGitBranchCompareFileDiff(repo, undefined, 'main', 'feature', 'src/b.txt')
  assert.equal(naive.changeType, 'CREATED')
  assert.equal(naive.beforeContent, '')

  // 传 oldPath(清单 R 行的旧路径 = base 侧路径):before 取自 base tip 的旧路径,changeType 对齐「改名」
  const renamed = await getGitBranchCompareFileDiff(repo, undefined, 'main', 'feature', 'src/b.txt', 'src/a.txt')
  assert.equal(renamed.path, 'src/b.txt')
  assert.equal(renamed.changeType, 'RENAMED')
  assert.equal(renamed.beforeContent, 'old content\n')
  assert.equal(renamed.afterContent, 'new content\n')
})

test('branch compare file diff takes the before side from the base tip', async (t) => {
  const repo = initRepo(t)
  commitFile(repo, 'f.txt', 'base\n', 'feat: add f.txt')
  git(repo, ['checkout', '-b', 'feature'])
  commitFile(repo, 'f.txt', 'feature\n', 'feat: feature edits f.txt')
  git(repo, ['checkout', 'main'])
  commitFile(repo, 'f.txt', 'main\n', 'feat: main edits f.txt')

  // 双方分叉且都改同一文件:清单是两点 diff(base tip → target tip),before 必须取 base 分支 tip('main\n')。
  // 基准侧的独有改动因此以反向修改出现(diff 从 'main' 变回 'feature'),属两点语义固有行为
  const diff = await getGitBranchCompareFileDiff(repo, undefined, 'main', 'feature', 'f.txt')
  assert.equal(diff.changeType, 'MODIFIED')
  assert.equal(diff.beforeContent, 'main\n')
  assert.equal(diff.afterContent, 'feature\n')
  // 与终端 `git diff main feature -- f.txt` 完全同内容:只有 base tip → target tip 的改动
  const terminal = git(repo, ['diff', 'main', 'feature', '--', 'f.txt'])
  const hunks = terminal.split('\n').filter((line) => /^[-+]/.test(line) && !line.startsWith('---') && !line.startsWith('+++'))
  assert.deepEqual(hunks, ['-main', '+feature'])
})

test('branch compare file diff degrades a rename when the base side dropped the old path', async (t) => {
  const repo = initRepo(t)
  fs.mkdirSync(path.join(repo, 'src'))
  fs.writeFileSync(path.join(repo, 'src', 'a.txt'), 'old content\n')
  git(repo, ['add', '-A'])
  git(repo, ['commit', '-m', 'add src/a.txt'])
  git(repo, ['checkout', '-b', 'feature'])
  git(repo, ['mv', 'src/a.txt', 'src/b.txt'])
  fs.writeFileSync(path.join(repo, 'src', 'b.txt'), 'new content\n')
  git(repo, ['add', '-A'])
  git(repo, ['commit', '-m', 'rename and edit'])
  git(repo, ['checkout', 'main'])
  git(repo, ['rm', '-q', 'src/a.txt'])
  git(repo, ['commit', '-m', 'drop src/a.txt on main'])

  // base 侧已删除该旧路径:两点语义下 before 只能取 base tip(已无该路径)→ 取空、退化成 CREATED。
  // 属固有行为:两棵树里 base 根本没有 src/a.txt,清单本身只会给出 A src/b.txt(不带 oldPath)
  const files = await getGitBranchCompare(repo, undefined, 'main', 'feature')
  assert.deepEqual(files.files.map((file) => [file.path, file.changeType]), [['src/b.txt', 'CREATED']])

  const diff = await getGitBranchCompareFileDiff(repo, undefined, 'main', 'feature', 'src/b.txt', 'src/a.txt')
  assert.equal(diff.changeType, 'CREATED')
  assert.equal(diff.beforeContent, '')
  assert.equal(diff.afterContent, 'new content\n')
})

test('branch compare file diff degrades binary files', async (t) => {
  const repo = initRepo(t)
  fs.writeFileSync(path.join(repo, 'blob.bin'), Buffer.from([0x00, 0x01, 0x02, 0x00]))
  git(repo, ['add', '-A'])
  git(repo, ['commit', '-m', 'add blob.bin'])
  git(repo, ['checkout', '-b', 'feature'])
  fs.writeFileSync(path.join(repo, 'blob.bin'), Buffer.from([0x00, 0xff, 0xfe, 0x00]))
  git(repo, ['add', '-A'])
  git(repo, ['commit', '-m', 'modify blob.bin'])

  const diff = await getGitBranchCompareFileDiff(repo, undefined, 'main', 'feature', 'blob.bin')
  assert.equal(diff.path, 'blob.bin')
  assert.equal(diff.binary, true)
  assert.equal(diff.unavailableReason, '二进制文件,无法预览')
  assert.equal(diff.beforeContent, '')
  assert.equal(diff.afterContent, '')
})

test('branch compare file diff covers unrelated histories and bad inputs', async (t) => {
  const repo = initRepo(t)
  git(repo, ['checkout', '--orphan', 'orphan'])
  git(repo, ['rm', '-rf', '.'])
  commitFile(repo, 'orphan.txt', 'orphan\n', 'orphan root')

  // 两点语义没有 merge-base 步骤:unrelated histories 也能正常取 before/after;
  // before 取 base tip,base 树里没有该路径 → 取空、由 CREATED 表达
  const unrelated = await getGitBranchCompareFileDiff(repo, undefined, 'main', 'orphan', 'orphan.txt')
  assert.equal(unrelated.path, 'orphan.txt')
  assert.equal(unrelated.changeType, 'CREATED')
  assert.equal(unrelated.beforeContent, '')
  assert.equal(unrelated.afterContent, 'orphan\n')
  assert.equal(unrelated.unavailableReason, undefined)

  // 反向:README.md 只存在于 base 侧 → before 有内容、target 侧取不到 → DELETED
  const reversed = await getGitBranchCompareFileDiff(repo, undefined, 'main', 'orphan', 'README.md')
  assert.equal(reversed.changeType, 'DELETED')
  assert.equal(reversed.beforeContent, 'initial\n')
  assert.equal(reversed.afterContent, '')

  const missingRef = await getGitBranchCompareFileDiff(repo, undefined, 'main', 'nope', 'orphan.txt')
  assert.equal(missingRef.unavailableReason, '分支不存在')
  const invalidName = await getGitBranchCompareFileDiff(repo, undefined, '-x', 'orphan', 'orphan.txt')
  assert.equal(invalidName.unavailableReason, '分支不存在')

  const escape = await getGitBranchCompareFileDiff(repo, undefined, 'main', 'orphan', '../secrets.txt')
  assert.equal(escape.unavailableReason, '路径无效')
  const escapeOld = await getGitBranchCompareFileDiff(repo, undefined, 'main', 'orphan', 'orphan.txt', '../old.txt')
  assert.equal(escapeOld.unavailableReason, '路径无效')
})

test('branch compare resolves branch refs even when a same-named tag shadows them', async (t) => {
  const repo = initRepo(t)
  commitFile(repo, 'base.txt', 'base\n', 'feat: fork point')
  const fork = git(repo, ['rev-parse', 'refs/heads/main'])
  git(repo, ['checkout', '-b', 'feature'])
  commitFile(repo, 'f.txt', 'feature\n', 'feat: feature commit')
  git(repo, ['checkout', 'main'])
  commitFile(repo, 'm.txt', 'main\n', 'feat: main commit')
  // 同名 tag 指向分叉点(既不是 main tip 也不是 feature tip)
  git(repo, ['tag', 'feature', fork])
  git(repo, ['tag', 'origin/feature', fork])
  git(repo, ['update-ref', 'refs/remotes/origin/feature', git(repo, ['rev-parse', 'refs/heads/feature'])])

  // git 的 refname 解析顺序 tag 先于 heads:裸名字会解析到 tag(且 stderr 打 ambiguous 警告),
  // 这正是「下拉选分支、实际比 tag」的静默错误成因;显式 refs/heads/ 才是分支
  assert.equal(git(repo, ['rev-parse', '--verify', 'feature']), fork)
  assert.equal(git(repo, ['rev-parse', '--verify', 'origin/feature']), fork)

  const result = await getGitBranchCompare(repo, undefined, 'main', 'feature')
  // 返回原始分支名;计数与清单等于两条分支 ref 的两点对比(tag 指向的提交不参与)
  assert.equal(result.base, 'main')
  assert.equal(result.target, 'feature')
  assert.equal(result.aheadCount, 1)
  assert.equal(result.behindCount, 1)
  assert.deepEqual(result.files.map((file) => [file.path, file.changeType]).sort(), [['f.txt', 'CREATED'], ['m.txt', 'DELETED']])

  // 第二段 refs/remotes/ 同样不被同名 tag 遮蔽(下拉里的远端跟踪分支形态)
  const remoteSide = await getGitBranchCompare(repo, undefined, 'main', 'origin/feature')
  assert.equal(remoteSide.aheadCount, 1)
  assert.equal(remoteSide.behindCount, 1)
  assert.deepEqual(remoteSide.files.map((file) => file.path).sort(), ['f.txt', 'm.txt'])

  // 只有 tag、没有同名分支的名字不是合法端点(对比端点仅限分支)
  git(repo, ['tag', 'release-only', fork])
  assert.equal(git(repo, ['rev-parse', '--verify', 'release-only']), fork)
  await assert.rejects(() => getGitBranchCompare(repo, undefined, 'main', 'release-only'), /分支不存在/)
  const tagOnlyDiff = await getGitBranchCompareFileDiff(repo, undefined, 'main', 'release-only', 'base.txt')
  assert.equal(tagOnlyDiff.unavailableReason, '分支不存在')
})

test('branch compare file diff takes the target branch, not a same-named tag', async (t) => {
  const repo = initRepo(t)
  commitFile(repo, 'f.txt', 'base\n', 'feat: add f.txt')
  git(repo, ['checkout', '-b', 'feature'])
  commitFile(repo, 'f.txt', 'feature\n', 'feat: feature edits f.txt')
  git(repo, ['checkout', 'main'])
  commitFile(repo, 'f.txt', 'main\n', 'feat: main edits f.txt')
  const mainTip = git(repo, ['rev-parse', 'refs/heads/main'])
  // tag 指向 main tip:裸名字会解析到 tag,before/after 会双双变成 main 的内容
  git(repo, ['tag', 'feature', mainTip])
  assert.equal(git(repo, ['rev-parse', '--verify', 'feature']), mainTip)

  const diff = await getGitBranchCompareFileDiff(repo, undefined, 'main', 'feature', 'f.txt')
  assert.equal(diff.path, 'f.txt')
  assert.equal(diff.changeType, 'MODIFIED')
  // before 取 base 分支 tip(两点语义)、after 取 feature 分支 tip —— 都不受同名 tag 影响
  assert.equal(diff.beforeContent, 'main\n')
  assert.equal(diff.afterContent, 'feature\n')
})

test('branch compare file diff truncates >2MiB files instead of returning an empty diff', async (t) => {
  const repo = initRepo(t)
  commitFile(repo, 'big.txt', 'a'.repeat(3 * 1024 * 1024), 'feat: add oversize file')
  git(repo, ['checkout', '-b', 'feature'])
  commitFile(repo, 'big.txt', 'b'.repeat(3 * 1024 * 1024), 'feat: rewrite oversize file')

  const diff = await getGitBranchCompareFileDiff(repo, undefined, 'main', 'feature', 'big.txt')
  assert.equal(diff.changeType, 'MODIFIED')
  // execFile maxBuffer(2 MiB)溢出:保留预算内已收集内容并截到 512 KiB,
  // 而不是把两次 show 都当失败、静默渲染成空 diff(与 CLOUD 侧行为一致)
  assert.equal(diff.truncated, true)
  assert.equal(diff.beforeContent.length, 512 * 1024)
  assert.equal(diff.afterContent.length, 512 * 1024)
  assert.equal(diff.beforeContent[0], 'a')
  assert.equal(diff.afterContent[0], 'b')
  assert.equal(diff.unavailableReason, undefined)
})

test('refreshGitStatus fetches every remote including non-origin ones', async (t) => {
  const repo = initRepo(t)
  const alpha = createBareRemote(t)
  const beta = createBareRemote(t)
  git(repo, ['remote', 'add', 'alpha', alpha])
  git(repo, ['remote', 'add', 'beta', beta])
  const branch = git(repo, ['rev-parse', '--abbrev-ref', 'HEAD'])
  git(repo, ['push', '-q', 'alpha', branch])
  git(repo, ['push', '-q', 'beta', branch])

  // 两个远端各自推进一个提交(从 alpha 克隆后分别推送)
  const seed = tempDir(t)
  git(seed, ['clone', '-q', '-b', branch, alpha, '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, 'a.txt'), 'a\n')
  git(work, ['add', '-A'])
  git(work, ['commit', '-m', 'a'])
  git(work, ['push', '-q', 'origin', branch])
  fs.writeFileSync(path.join(work, 'b.txt'), 'b\n')
  git(work, ['add', '-A'])
  git(work, ['commit', '-m', 'b'])
  git(work, ['push', '-q', beta, branch])

  const status = await refreshGitStatus(repo)
  assert.equal(status.remoteStatusAvailable, true)
  // 两个非 origin 远端且无 upstream:参照远端无法确定,hasCommitsToPush 按 false
  assert.equal(status.hasCommitsToPush, false)
  assert.equal(git(repo, ['rev-parse', `alpha/${branch}`]), git(alpha, ['rev-parse', branch]))
  assert.equal(git(repo, ['rev-parse', `beta/${branch}`]), git(beta, ['rev-parse', branch]))
})

test('refreshGitStatus keeps fetching other remotes when one hangs', async (t) => {
  const repo = initRepo(t)
  const good = createBareRemote(t)
  const branch = git(repo, ['rev-parse', '--abbrev-ref', 'HEAD'])
  git(repo, ['remote', 'add', 'aaa-hang', await startHangingRemote(t)])
  git(repo, ['remote', 'add', 'zzz-good', good])
  git(repo, ['push', '-q', 'zzz-good', branch])

  const status = await withGitTimeout(2000, () => refreshGitStatus(repo))
  assert.equal(status.remoteStatusAvailable, true)
  assert.match(status.remoteStatusError, /部分远端获取失败/)
  assert.match(status.remoteStatusError, /aaa-hang/)
  assert.equal(status.remoteStatusError.includes('zzz-good'), false)
  // 挂起远端超时不影响其余远端:zzz-good 的跟踪引用更新到远端实际 sha
  assert.equal(git(repo, ['rev-parse', `zzz-good/${branch}`]), git(good, ['rev-parse', branch]))
})

test('refreshGitStatus names every hanging remote and bounds the total wait', 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 status = await withGitTimeout(2000, () => refreshGitStatus(repo))
  const elapsed = Date.now() - started
  // 3 个挂起远端并行获取:总等待 ≍ 单个远端预算(串行会叠成 ~3 倍)
  assert.ok(elapsed < 4000, `elapsed=${elapsed}ms`)
  assert.equal(status.remoteStatusAvailable, false)
  assert.equal(status.remoteStatusError, '全部远端获取失败:h1、h2、h3')
})

test('refreshGitStatus reports the timeout message for a single hanging remote', async (t) => {
  const repo = initRepo(t)
  git(repo, ['remote', 'add', 'only-hang', await startHangingRemote(t)])

  const status = await withGitTimeout(2000, () => refreshGitStatus(repo))
  assert.equal(status.remoteStatusAvailable, false)
  assert.equal(status.remoteStatusError, 'Git fetch 超过 2 秒,已终止')
})