feat: support local-only git commits without remotes
This commit is contained in:
@@ -2,28 +2,58 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useCommitFlow } from './useCommitFlow'
|
||||
|
||||
const mockInvokeFn = vi.fn()
|
||||
const mockTrackEvent = vi.fn()
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: (command: string, args: Record<string, unknown>) => mockInvokeFn(command, args),
|
||||
}))
|
||||
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
mockInvoke: (command: string, args: Record<string, unknown>) => mockInvokeFn(command, args),
|
||||
}))
|
||||
|
||||
vi.mock('../lib/telemetry', () => ({
|
||||
trackEvent: (event: string, properties?: Record<string, unknown>) => mockTrackEvent(event, properties),
|
||||
}))
|
||||
|
||||
describe('useCommitFlow', () => {
|
||||
let savePending: vi.Mock
|
||||
let loadModifiedFiles: vi.Mock
|
||||
let commitAndPush: vi.Mock
|
||||
let resolveRemoteStatus: vi.Mock
|
||||
let setToastMessage: vi.Mock
|
||||
let onPushRejected: vi.Mock
|
||||
|
||||
beforeEach(() => {
|
||||
savePending = vi.fn().mockResolvedValue(undefined)
|
||||
loadModifiedFiles = vi.fn().mockResolvedValue(undefined)
|
||||
commitAndPush = vi.fn().mockResolvedValue({ status: 'ok', message: 'Pushed to remote' })
|
||||
resolveRemoteStatus = vi.fn().mockResolvedValue({ branch: 'main', ahead: 0, behind: 0, hasRemote: true })
|
||||
setToastMessage = vi.fn()
|
||||
onPushRejected = vi.fn()
|
||||
mockTrackEvent.mockReset()
|
||||
mockInvokeFn.mockReset()
|
||||
mockInvokeFn.mockImplementation((command: string) => {
|
||||
if (command === 'git_commit') return Promise.resolve('[main abc1234] test commit')
|
||||
if (command === 'git_push') return Promise.resolve({ status: 'ok', message: 'Pushed to remote' })
|
||||
throw new Error(`Unexpected command: ${command}`)
|
||||
})
|
||||
})
|
||||
|
||||
function renderCommitFlow() {
|
||||
return renderHook(() => useCommitFlow({ savePending, loadModifiedFiles, commitAndPush, setToastMessage, onPushRejected }))
|
||||
return renderHook(() => useCommitFlow({
|
||||
savePending,
|
||||
loadModifiedFiles,
|
||||
resolveRemoteStatus,
|
||||
setToastMessage,
|
||||
onPushRejected,
|
||||
vaultPath: '/vault',
|
||||
}))
|
||||
}
|
||||
|
||||
it('openCommitDialog saves pending, refreshes files, then opens dialog', async () => {
|
||||
it('openCommitDialog saves pending, refreshes files, and sets local mode when no remote exists', async () => {
|
||||
resolveRemoteStatus.mockResolvedValueOnce({ branch: 'main', ahead: 0, behind: 0, hasRemote: false })
|
||||
const { result } = renderCommitFlow()
|
||||
expect(result.current.showCommitDialog).toBe(false)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.openCommitDialog()
|
||||
@@ -31,10 +61,12 @@ describe('useCommitFlow', () => {
|
||||
|
||||
expect(savePending).toHaveBeenCalledTimes(1)
|
||||
expect(loadModifiedFiles).toHaveBeenCalledTimes(1)
|
||||
expect(resolveRemoteStatus).toHaveBeenCalledTimes(1)
|
||||
expect(result.current.showCommitDialog).toBe(true)
|
||||
expect(result.current.commitMode).toBe('local')
|
||||
})
|
||||
|
||||
it('handleCommitPush saves pending, commits, shows toast, and refreshes files', async () => {
|
||||
it('handleCommitPush commits and pushes when a remote is configured', async () => {
|
||||
const { result } = renderCommitFlow()
|
||||
|
||||
await act(async () => {
|
||||
@@ -42,14 +74,39 @@ describe('useCommitFlow', () => {
|
||||
})
|
||||
|
||||
expect(savePending).toHaveBeenCalled()
|
||||
expect(commitAndPush).toHaveBeenCalledWith('test message')
|
||||
expect(mockInvokeFn).toHaveBeenNthCalledWith(1, 'git_commit', { vaultPath: '/vault', message: 'test message' })
|
||||
expect(mockInvokeFn).toHaveBeenNthCalledWith(2, 'git_push', { vaultPath: '/vault' })
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Committed and pushed')
|
||||
expect(loadModifiedFiles).toHaveBeenCalled()
|
||||
expect(resolveRemoteStatus).toHaveBeenCalledTimes(2)
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('commit_made', undefined)
|
||||
expect(result.current.showCommitDialog).toBe(false)
|
||||
})
|
||||
|
||||
it('handleCommitPush commits locally and skips push when no remote is configured', async () => {
|
||||
resolveRemoteStatus.mockResolvedValue({ branch: 'main', ahead: 0, behind: 0, hasRemote: false })
|
||||
mockInvokeFn.mockImplementation((command: string) => {
|
||||
if (command === 'git_commit') return Promise.resolve('[main abc1234] test message')
|
||||
throw new Error(`Unexpected command: ${command}`)
|
||||
})
|
||||
const { result } = renderCommitFlow()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCommitPush('test message')
|
||||
})
|
||||
|
||||
expect(mockInvokeFn).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('git_commit', { vaultPath: '/vault', message: 'test message' })
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Committed locally (no remote configured)')
|
||||
expect(onPushRejected).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handleCommitPush calls onPushRejected when push is rejected', async () => {
|
||||
commitAndPush.mockResolvedValueOnce({ status: 'rejected', message: 'Push rejected' })
|
||||
mockInvokeFn.mockImplementation((command: string) => {
|
||||
if (command === 'git_commit') return Promise.resolve('[main abc1234] test message')
|
||||
if (command === 'git_push') return Promise.resolve({ status: 'rejected', message: 'Push rejected' })
|
||||
throw new Error(`Unexpected command: ${command}`)
|
||||
})
|
||||
const { result } = renderCommitFlow()
|
||||
|
||||
await act(async () => {
|
||||
@@ -61,7 +118,7 @@ describe('useCommitFlow', () => {
|
||||
})
|
||||
|
||||
it('handleCommitPush shows error toast on failure', async () => {
|
||||
commitAndPush.mockRejectedValueOnce(new Error('push failed'))
|
||||
mockInvokeFn.mockImplementation(() => Promise.reject(new Error('push failed')))
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { result } = renderCommitFlow()
|
||||
|
||||
@@ -69,7 +126,7 @@ describe('useCommitFlow', () => {
|
||||
await result.current.handleCommitPush('test')
|
||||
})
|
||||
|
||||
expect(setToastMessage).toHaveBeenCalledWith(expect.stringContaining('Commit failed'))
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Commit failed: push failed')
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,47 +1,115 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import type { GitPushResult } from '../types'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import type { GitPushResult, GitRemoteStatus } from '../types'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
|
||||
export type CommitMode = 'push' | 'local'
|
||||
|
||||
interface LocalCommitResult {
|
||||
status: 'local_only'
|
||||
message: string
|
||||
}
|
||||
|
||||
type CommitResult = GitPushResult | LocalCommitResult
|
||||
|
||||
interface CommitFlowConfig {
|
||||
savePending: () => Promise<void | boolean>
|
||||
loadModifiedFiles: () => Promise<void>
|
||||
commitAndPush: (message: string) => Promise<GitPushResult>
|
||||
resolveRemoteStatus: () => Promise<GitRemoteStatus | null>
|
||||
setToastMessage: (msg: string | null) => void
|
||||
onPushRejected?: () => void
|
||||
vaultPath: string
|
||||
}
|
||||
|
||||
/** Manages the Commit & Push dialog state and the save→commit→push flow. */
|
||||
export function useCommitFlow({ savePending, loadModifiedFiles, commitAndPush, setToastMessage, onPushRejected }: CommitFlowConfig) {
|
||||
function commitModeFromRemoteStatus(remoteStatus: GitRemoteStatus | null): CommitMode {
|
||||
return remoteStatus?.hasRemote === false ? 'local' : 'push'
|
||||
}
|
||||
|
||||
async function commitLocally(vaultPath: string, message: string): Promise<void> {
|
||||
if (!isTauri()) {
|
||||
await mockInvoke<string>('git_commit', { vaultPath, message })
|
||||
return
|
||||
}
|
||||
|
||||
await invoke<string>('git_commit', { vaultPath, message })
|
||||
}
|
||||
|
||||
async function pushCommittedChanges(vaultPath: string): Promise<GitPushResult> {
|
||||
if (!isTauri()) {
|
||||
return mockInvoke<GitPushResult>('git_push', { vaultPath })
|
||||
}
|
||||
|
||||
return invoke<GitPushResult>('git_push', { vaultPath })
|
||||
}
|
||||
|
||||
async function executeCommitAction(vaultPath: string, message: string, commitMode: CommitMode): Promise<CommitResult> {
|
||||
await commitLocally(vaultPath, message)
|
||||
if (commitMode === 'local') {
|
||||
return { status: 'local_only', message: 'Committed locally (no remote configured)' }
|
||||
}
|
||||
|
||||
return pushCommittedChanges(vaultPath)
|
||||
}
|
||||
|
||||
function commitToastMessage(result: CommitResult): string {
|
||||
if (result.status === 'ok') return 'Committed and pushed'
|
||||
if (result.status === 'local_only') return result.message
|
||||
if (result.status === 'rejected') return 'Committed, but push rejected — remote has new commits. Pull first.'
|
||||
return result.message
|
||||
}
|
||||
|
||||
function isPushRejected(result: CommitResult): boolean {
|
||||
return result.status === 'rejected'
|
||||
}
|
||||
|
||||
function formatCommitError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
/** Manages the commit dialog state and the save→commit→push/local flow. */
|
||||
export function useCommitFlow({
|
||||
savePending,
|
||||
loadModifiedFiles,
|
||||
resolveRemoteStatus,
|
||||
setToastMessage,
|
||||
onPushRejected,
|
||||
vaultPath,
|
||||
}: CommitFlowConfig) {
|
||||
const [showCommitDialog, setShowCommitDialog] = useState(false)
|
||||
const [commitMode, setCommitMode] = useState<CommitMode>('push')
|
||||
|
||||
const openCommitDialog = useCallback(async () => {
|
||||
await savePending()
|
||||
await loadModifiedFiles()
|
||||
const remoteStatus = await resolveRemoteStatus()
|
||||
setCommitMode(commitModeFromRemoteStatus(remoteStatus))
|
||||
setShowCommitDialog(true)
|
||||
}, [savePending, loadModifiedFiles])
|
||||
}, [loadModifiedFiles, resolveRemoteStatus, savePending])
|
||||
|
||||
const handleCommitPush = useCallback(async (message: string) => {
|
||||
setShowCommitDialog(false)
|
||||
try {
|
||||
await savePending()
|
||||
const result = await commitAndPush(message)
|
||||
if (result.status === 'ok') {
|
||||
trackEvent('commit_made')
|
||||
setToastMessage('Committed and pushed')
|
||||
} else if (result.status === 'rejected') {
|
||||
setToastMessage('Committed, but push rejected — remote has new commits. Pull first.')
|
||||
const remoteStatus = await resolveRemoteStatus()
|
||||
const nextCommitMode = commitModeFromRemoteStatus(remoteStatus)
|
||||
const result = await executeCommitAction(vaultPath, message, nextCommitMode)
|
||||
|
||||
trackEvent('commit_made')
|
||||
setToastMessage(commitToastMessage(result))
|
||||
if (isPushRejected(result)) {
|
||||
onPushRejected?.()
|
||||
} else {
|
||||
setToastMessage(result.message)
|
||||
}
|
||||
loadModifiedFiles()
|
||||
|
||||
await loadModifiedFiles()
|
||||
await resolveRemoteStatus()
|
||||
} catch (err) {
|
||||
console.error('Commit failed:', err)
|
||||
setToastMessage(`Commit failed: ${err}`)
|
||||
setToastMessage(`Commit failed: ${formatCommitError(err)}`)
|
||||
}
|
||||
}, [savePending, commitAndPush, loadModifiedFiles, setToastMessage, onPushRejected])
|
||||
}, [loadModifiedFiles, onPushRejected, resolveRemoteStatus, savePending, setToastMessage, vaultPath])
|
||||
|
||||
const closeCommitDialog = useCallback(() => setShowCommitDialog(false), [])
|
||||
|
||||
return { showCommitDialog, openCommitDialog, handleCommitPush, closeCommitDialog }
|
||||
return { showCommitDialog, commitMode, openCommitDialog, handleCommitPush, closeCommitDialog }
|
||||
}
|
||||
|
||||
49
src/hooks/useGitRemoteStatus.test.ts
Normal file
49
src/hooks/useGitRemoteStatus.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { renderHook, waitFor, act } from '@testing-library/react'
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { useGitRemoteStatus } from './useGitRemoteStatus'
|
||||
|
||||
const mockInvokeFn = vi.fn()
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: (command: string, args: Record<string, unknown>) => mockInvokeFn(command, args),
|
||||
}))
|
||||
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
mockInvoke: (command: string, args: Record<string, unknown>) => mockInvokeFn(command, args),
|
||||
}))
|
||||
|
||||
describe('useGitRemoteStatus', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('loads remote status on mount', async () => {
|
||||
mockInvokeFn.mockResolvedValue({ branch: 'main', ahead: 0, behind: 0, hasRemote: false })
|
||||
|
||||
const { result } = renderHook(() => useGitRemoteStatus('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.remoteStatus).toEqual({ branch: 'main', ahead: 0, behind: 0, hasRemote: false })
|
||||
})
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('git_remote_status', { vaultPath: '/vault' })
|
||||
})
|
||||
|
||||
it('refreshRemoteStatus updates the current remote state', async () => {
|
||||
mockInvokeFn
|
||||
.mockResolvedValueOnce({ branch: 'main', ahead: 0, behind: 0, hasRemote: true })
|
||||
.mockResolvedValueOnce({ branch: 'main', ahead: 1, behind: 0, hasRemote: false })
|
||||
|
||||
const { result } = renderHook(() => useGitRemoteStatus('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.remoteStatus?.hasRemote).toBe(true)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshRemoteStatus()
|
||||
})
|
||||
|
||||
expect(result.current.remoteStatus).toEqual({ branch: 'main', ahead: 1, behind: 0, hasRemote: false })
|
||||
})
|
||||
})
|
||||
52
src/hooks/useGitRemoteStatus.ts
Normal file
52
src/hooks/useGitRemoteStatus.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { GitRemoteStatus } from '../types'
|
||||
|
||||
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
|
||||
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
|
||||
}
|
||||
|
||||
export interface GitRemoteState {
|
||||
remoteStatus: GitRemoteStatus | null
|
||||
refreshRemoteStatus: () => Promise<GitRemoteStatus | null>
|
||||
}
|
||||
|
||||
async function readRemoteStatus(vaultPath: string): Promise<GitRemoteStatus> {
|
||||
return tauriCall<GitRemoteStatus>('git_remote_status', { vaultPath })
|
||||
}
|
||||
|
||||
export function useGitRemoteStatus(vaultPath: string): GitRemoteState {
|
||||
const [remoteStatus, setRemoteStatus] = useState<GitRemoteStatus | null>(null)
|
||||
|
||||
const refreshRemoteStatus = useCallback(async () => {
|
||||
try {
|
||||
const status = await readRemoteStatus(vaultPath)
|
||||
setRemoteStatus(status)
|
||||
return status
|
||||
} catch {
|
||||
setRemoteStatus(null)
|
||||
return null
|
||||
}
|
||||
}, [vaultPath])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function loadRemoteStatus() {
|
||||
try {
|
||||
const status = await readRemoteStatus(vaultPath)
|
||||
if (!cancelled) setRemoteStatus(status)
|
||||
} catch {
|
||||
if (!cancelled) setRemoteStatus(null)
|
||||
}
|
||||
}
|
||||
|
||||
void loadRemoteStatus()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [vaultPath])
|
||||
|
||||
return { remoteStatus, refreshRemoteStatus }
|
||||
}
|
||||
Reference in New Issue
Block a user