feat(git): 工作区 Git 同步——侧栏同步条、远程设置、定时同步(提交→变基拉取→推送,冲突停下不覆盖)(docs/27)

This commit is contained in:
Muse
2026-11-14 17:35:41 +08:00
parent 13271f11b6
commit fadee6aacf
11 changed files with 792 additions and 2 deletions

123
tests/git-sync.test.tsx Normal file
View File

@@ -0,0 +1,123 @@
// Git 同步:设置解析、状态文案、定时租约互斥、侧栏同步条渲染。
import { describe, it, expect, vi } from 'vitest'
import { createRoot } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import {
parseGitSyncSettings, gitStatusLabel, gitSyncCommitMessage,
acquireGitSyncLease, releaseGitSyncLease,
} from '../src/services/gitSync'
import type { GitStatusInfo } from '../src/services/platform'
vi.mock('../src/services/platform', async (importOriginal) => {
const orig = await importOriginal<typeof import('../src/services/platform')>()
return { ...orig, listDir: async () => [] }
})
import Sidebar from '../src/components/Sidebar'
const base: GitStatusInfo = {
isRepo: true, branch: 'main', remoteUrl: 'https://example.com/n.git',
dirtyCount: 0, ahead: 0, behind: 0, hasUpstream: true,
}
describe('parseGitSyncSettings', () => {
it('空/坏数据回默认(手动)', () => {
expect(parseGitSyncSettings(null).intervalMin).toBe(0)
expect(parseGitSyncSettings('{bad').intervalMin).toBe(0)
expect(parseGitSyncSettings('{"intervalMin":-3}').intervalMin).toBe(0)
})
it('正常解析间隔', () => {
expect(parseGitSyncSettings('{"intervalMin":15}').intervalMin).toBe(15)
})
})
describe('gitStatusLabel', () => {
it('未启用 / 同步中 / 已同步', () => {
expect(gitStatusLabel({ ...base, isRepo: false }, false)).toBe('未启用 Git 同步')
expect(gitStatusLabel(base, true)).toBe('正在同步…')
expect(gitStatusLabel(base, false)).toBe('main · 已同步')
})
it('待提交/待推送/待拉取计数', () => {
expect(gitStatusLabel({ ...base, dirtyCount: 2, ahead: 1, behind: 3 }, false))
.toBe('main · 待提交 2 处 · 待推送 1 · 待拉取 3')
})
it('无远程时提示未设置远程', () => {
expect(gitStatusLabel({ ...base, remoteUrl: null, hasUpstream: false }, false))
.toBe('main · 未设置远程 · 已同步')
})
})
describe('gitSyncCommitMessage', () => {
it('带本地时间戳与手动/自动区分', () => {
const d = new Date(2026, 10, 14, 9, 5)
expect(gitSyncCommitMessage(true, d)).toBe('自动同步 2026-11-14 09:05(new-markdown)')
expect(gitSyncCommitMessage(false, d)).toContain('手动同步 2026-11-14 09:05')
})
})
describe('定时同步租约', () => {
it('别的窗口新鲜占用时取不到,过期/释放后可取', () => {
localStorage.clear()
const t0 = 1_000_000
expect(acquireGitSyncLease('C:/ws', 'win-a', t0)).toBe(true)
expect(acquireGitSyncLease('C:/ws', 'win-b', t0 + 5_000)).toBe(false)
expect(acquireGitSyncLease('C:/ws', 'win-a', t0 + 6_000)).toBe(true) // 自己续租
expect(acquireGitSyncLease('C:/ws', 'win-b', t0 + 40_000)).toBe(true) // 已过期
releaseGitSyncLease('C:/ws', 'win-a') // 不是持有者,无效
expect(acquireGitSyncLease('C:/ws', 'win-c', t0 + 41_000)).toBe(false)
releaseGitSyncLease('C:/ws', 'win-b')
expect(acquireGitSyncLease('C:/ws', 'win-c', t0 + 42_000)).toBe(true)
})
})
describe('侧栏 Git 同步条', () => {
async function renderBar(gitSync: Record<string, unknown>) {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
await act(async () => {
root.render(
<Sidebar
mode="files"
onModeChange={() => undefined}
workspaceRoot="C:/ws"
activePath={null}
onOpenFile={() => undefined}
onOpenFolder={() => undefined}
outline={[]}
onJumpHeading={() => undefined}
tauriMode
gitSync={gitSync as never}
/>,
)
})
await act(async () => { await new Promise((r) => setTimeout(r, 30)) })
return container
}
it('未启用时显示状态与设置按钮,无立即同步', async () => {
const c = await renderBar({
isRepo: false, label: '未启用 Git 同步', syncing: false,
lastMessage: null, lastIsError: false,
onSyncNow: () => undefined, onOpenSettings: () => undefined,
})
expect(c.textContent).toContain('Git 同步')
expect(c.textContent).toContain('未启用 Git 同步')
expect(c.querySelector('[aria-label="Git 同步设置"]')).not.toBeNull()
expect(c.querySelector('[aria-label="立即同步"]')).toBeNull()
})
it('仓库状态下显示立即同步且可点,结果行可见', async () => {
const onSyncNow = vi.fn()
const c = await renderBar({
isRepo: true, label: 'main · 待提交 2 处', syncing: false,
lastMessage: '已提交本地更改', lastIsError: false,
onSyncNow, onOpenSettings: () => undefined,
})
expect(c.textContent).toContain('待提交 2 处')
expect(c.textContent).toContain('已提交本地更改')
const btn = c.querySelector('[aria-label="立即同步"]') as HTMLButtonElement
await act(async () => { btn.click() })
expect(onSyncNow).toHaveBeenCalledTimes(1)
})
})