feat: add AutoGit idle and inactive checkpoints
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useCallback, useRef, useState, type MutableRefObject } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import type { GitPushResult, GitRemoteStatus } from '../types'
|
||||
import type { GitPushResult, GitRemoteStatus, ModifiedFile } from '../types'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import { generateAutomaticCommitMessage } from '../utils/automaticCommitMessage'
|
||||
|
||||
export type CommitMode = 'push' | 'local'
|
||||
|
||||
@@ -12,6 +13,11 @@ interface LocalCommitResult {
|
||||
}
|
||||
|
||||
type CommitResult = GitPushResult | LocalCommitResult
|
||||
type CheckpointAction = 'commit' | 'push_only'
|
||||
|
||||
interface AutomaticCheckpointOptions {
|
||||
savePendingBeforeCommit?: boolean
|
||||
}
|
||||
|
||||
interface CommitFlowConfig {
|
||||
savePending: () => Promise<void | boolean>
|
||||
@@ -22,11 +28,37 @@ interface CommitFlowConfig {
|
||||
vaultPath: string
|
||||
}
|
||||
|
||||
interface VaultPathArgs {
|
||||
vaultPath: string
|
||||
}
|
||||
|
||||
interface CommitArgs extends VaultPathArgs {
|
||||
message: string
|
||||
}
|
||||
|
||||
interface CommitExecutionArgs extends CommitArgs {
|
||||
commitMode: CommitMode
|
||||
}
|
||||
|
||||
interface AutomaticCheckpointContext extends VaultPathArgs {
|
||||
remoteStatus: GitRemoteStatus | null
|
||||
}
|
||||
|
||||
interface AutomaticCheckpointCommand extends AutomaticCheckpointContext {
|
||||
action: CheckpointAction
|
||||
message?: string
|
||||
}
|
||||
|
||||
interface ExecutedCheckpoint {
|
||||
action: CheckpointAction
|
||||
result: CommitResult
|
||||
}
|
||||
|
||||
function commitModeFromRemoteStatus(remoteStatus: GitRemoteStatus | null): CommitMode {
|
||||
return remoteStatus?.hasRemote === false ? 'local' : 'push'
|
||||
}
|
||||
|
||||
async function commitLocally(vaultPath: string, message: string): Promise<void> {
|
||||
async function commitLocally({ vaultPath, message }: CommitArgs): Promise<void> {
|
||||
if (!isTauri()) {
|
||||
await mockInvoke<string>('git_commit', { vaultPath, message })
|
||||
return
|
||||
@@ -35,7 +67,7 @@ async function commitLocally(vaultPath: string, message: string): Promise<void>
|
||||
await invoke<string>('git_commit', { vaultPath, message })
|
||||
}
|
||||
|
||||
async function pushCommittedChanges(vaultPath: string): Promise<GitPushResult> {
|
||||
async function pushCommittedChanges({ vaultPath }: VaultPathArgs): Promise<GitPushResult> {
|
||||
if (!isTauri()) {
|
||||
return mockInvoke<GitPushResult>('git_push', { vaultPath })
|
||||
}
|
||||
@@ -43,13 +75,25 @@ async function pushCommittedChanges(vaultPath: string): Promise<GitPushResult> {
|
||||
return invoke<GitPushResult>('git_push', { vaultPath })
|
||||
}
|
||||
|
||||
async function executeCommitAction(vaultPath: string, message: string, commitMode: CommitMode): Promise<CommitResult> {
|
||||
await commitLocally(vaultPath, message)
|
||||
async function readModifiedFiles({ vaultPath }: VaultPathArgs): Promise<ModifiedFile[]> {
|
||||
if (!isTauri()) {
|
||||
return mockInvoke<ModifiedFile[]>('get_modified_files', { vaultPath })
|
||||
}
|
||||
|
||||
return invoke<ModifiedFile[]>('get_modified_files', { vaultPath })
|
||||
}
|
||||
|
||||
async function executeCommitAction({
|
||||
vaultPath,
|
||||
message,
|
||||
commitMode,
|
||||
}: CommitExecutionArgs): Promise<CommitResult> {
|
||||
await commitLocally({ vaultPath, message })
|
||||
if (commitMode === 'local') {
|
||||
return { status: 'local_only', message: 'Committed locally (no remote configured)' }
|
||||
}
|
||||
|
||||
return pushCommittedChanges(vaultPath)
|
||||
return pushCommittedChanges({ vaultPath })
|
||||
}
|
||||
|
||||
function commitToastMessage(result: CommitResult): string {
|
||||
@@ -67,6 +111,182 @@ function formatCommitError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
function shouldRetryPush(remoteStatus: GitRemoteStatus | null): boolean {
|
||||
return remoteStatus?.hasRemote === true && remoteStatus.ahead > 0
|
||||
}
|
||||
|
||||
function nothingToCommitToast(remoteStatus: GitRemoteStatus | null): string {
|
||||
return remoteStatus?.hasRemote === false ? 'Nothing to commit' : 'Nothing to commit or push'
|
||||
}
|
||||
|
||||
function checkpointToastMessage(result: CommitResult, action: CheckpointAction): string {
|
||||
if (action === 'push_only') {
|
||||
if (result.status === 'ok') return 'Pushed committed changes'
|
||||
if (result.status === 'rejected') return 'Push rejected — remote has new commits. Pull first.'
|
||||
return result.message
|
||||
}
|
||||
|
||||
return commitToastMessage(result)
|
||||
}
|
||||
|
||||
function createAutomaticCheckpointCommand({
|
||||
remoteStatus,
|
||||
vaultPath,
|
||||
message,
|
||||
}: AutomaticCheckpointContext & { message: string }): AutomaticCheckpointCommand | null {
|
||||
if (message.length > 0) {
|
||||
return { action: 'commit', remoteStatus, vaultPath, message }
|
||||
}
|
||||
|
||||
if (shouldRetryPush(remoteStatus)) {
|
||||
return { action: 'push_only', remoteStatus, vaultPath }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function executeAutomaticCheckpoint(
|
||||
command: AutomaticCheckpointCommand,
|
||||
): Promise<ExecutedCheckpoint> {
|
||||
if (command.action === 'push_only') {
|
||||
return {
|
||||
action: 'push_only',
|
||||
result: await pushCommittedChanges({ vaultPath: command.vaultPath }),
|
||||
}
|
||||
}
|
||||
|
||||
const result = await executeCommitAction({
|
||||
vaultPath: command.vaultPath,
|
||||
message: command.message ?? '',
|
||||
commitMode: commitModeFromRemoteStatus(command.remoteStatus),
|
||||
})
|
||||
trackEvent('commit_made')
|
||||
return { action: 'commit', result }
|
||||
}
|
||||
|
||||
async function runCheckpointRefresh({
|
||||
loadModifiedFiles,
|
||||
resolveRemoteStatus,
|
||||
}: Pick<CommitFlowConfig, 'loadModifiedFiles' | 'resolveRemoteStatus'>): Promise<void> {
|
||||
await loadModifiedFiles()
|
||||
await resolveRemoteStatus()
|
||||
}
|
||||
|
||||
async function finalizeCheckpoint({
|
||||
result,
|
||||
toastMessage,
|
||||
loadModifiedFiles,
|
||||
resolveRemoteStatus,
|
||||
setToastMessage,
|
||||
onPushRejected,
|
||||
}: Pick<CommitFlowConfig, 'loadModifiedFiles' | 'resolveRemoteStatus' | 'setToastMessage' | 'onPushRejected'> & {
|
||||
result: CommitResult
|
||||
toastMessage: string
|
||||
}): Promise<void> {
|
||||
setToastMessage(toastMessage)
|
||||
if (isPushRejected(result)) {
|
||||
onPushRejected?.()
|
||||
}
|
||||
|
||||
await runCheckpointRefresh({ loadModifiedFiles, resolveRemoteStatus })
|
||||
}
|
||||
|
||||
function useAutomaticCheckpointAction({
|
||||
checkpointInFlightRef,
|
||||
savePending,
|
||||
loadModifiedFiles,
|
||||
resolveRemoteStatus,
|
||||
setToastMessage,
|
||||
onPushRejected,
|
||||
vaultPath,
|
||||
}: CommitFlowConfig & {
|
||||
checkpointInFlightRef: MutableRefObject<boolean>
|
||||
}) {
|
||||
return useCallback(async ({
|
||||
savePendingBeforeCommit = false,
|
||||
}: AutomaticCheckpointOptions = {}): Promise<boolean> => {
|
||||
if (checkpointInFlightRef.current) return false
|
||||
checkpointInFlightRef.current = true
|
||||
|
||||
try {
|
||||
if (savePendingBeforeCommit) {
|
||||
await savePending()
|
||||
}
|
||||
|
||||
const remoteStatus = await resolveRemoteStatus()
|
||||
const modifiedFiles = await readModifiedFiles({ vaultPath })
|
||||
const message = generateAutomaticCommitMessage(modifiedFiles)
|
||||
const command = createAutomaticCheckpointCommand({ remoteStatus, vaultPath, message })
|
||||
if (!command) {
|
||||
setToastMessage(nothingToCommitToast(remoteStatus))
|
||||
return false
|
||||
}
|
||||
|
||||
const { action, result } = await executeAutomaticCheckpoint(command)
|
||||
await finalizeCheckpoint({
|
||||
result,
|
||||
toastMessage: checkpointToastMessage(result, action),
|
||||
loadModifiedFiles,
|
||||
resolveRemoteStatus,
|
||||
setToastMessage,
|
||||
onPushRejected,
|
||||
})
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('Commit failed:', err)
|
||||
setToastMessage(`Commit failed: ${formatCommitError(err)}`)
|
||||
return false
|
||||
} finally {
|
||||
checkpointInFlightRef.current = false
|
||||
}
|
||||
}, [checkpointInFlightRef, loadModifiedFiles, onPushRejected, resolveRemoteStatus, savePending, setToastMessage, vaultPath])
|
||||
}
|
||||
|
||||
function useManualCommitPushAction({
|
||||
checkpointInFlightRef,
|
||||
savePending,
|
||||
loadModifiedFiles,
|
||||
resolveRemoteStatus,
|
||||
setToastMessage,
|
||||
onPushRejected,
|
||||
vaultPath,
|
||||
setShowCommitDialog,
|
||||
}: CommitFlowConfig & {
|
||||
checkpointInFlightRef: MutableRefObject<boolean>
|
||||
setShowCommitDialog: (open: boolean) => void
|
||||
}) {
|
||||
return useCallback(async (message: string) => {
|
||||
setShowCommitDialog(false)
|
||||
if (checkpointInFlightRef.current) return
|
||||
checkpointInFlightRef.current = true
|
||||
|
||||
try {
|
||||
await savePending()
|
||||
const remoteStatus = await resolveRemoteStatus()
|
||||
const result = await executeCommitAction({
|
||||
vaultPath,
|
||||
message,
|
||||
commitMode: commitModeFromRemoteStatus(remoteStatus),
|
||||
})
|
||||
|
||||
trackEvent('commit_made')
|
||||
await finalizeCheckpoint({
|
||||
result,
|
||||
toastMessage: commitToastMessage(result),
|
||||
loadModifiedFiles,
|
||||
resolveRemoteStatus,
|
||||
setToastMessage,
|
||||
onPushRejected,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Commit failed:', err)
|
||||
setToastMessage(`Commit failed: ${formatCommitError(err)}`)
|
||||
} finally {
|
||||
checkpointInFlightRef.current = false
|
||||
}
|
||||
}, [checkpointInFlightRef, loadModifiedFiles, onPushRejected, resolveRemoteStatus, savePending, setShowCommitDialog, setToastMessage, vaultPath])
|
||||
}
|
||||
|
||||
/** Manages the commit dialog state and the save→commit→push/local flow. */
|
||||
export function useCommitFlow({
|
||||
savePending,
|
||||
@@ -78,6 +298,7 @@ export function useCommitFlow({
|
||||
}: CommitFlowConfig) {
|
||||
const [showCommitDialog, setShowCommitDialog] = useState(false)
|
||||
const [commitMode, setCommitMode] = useState<CommitMode>('push')
|
||||
const checkpointInFlightRef = useRef(false)
|
||||
|
||||
const openCommitDialog = useCallback(async () => {
|
||||
await savePending()
|
||||
@@ -87,29 +308,28 @@ export function useCommitFlow({
|
||||
setShowCommitDialog(true)
|
||||
}, [loadModifiedFiles, resolveRemoteStatus, savePending])
|
||||
|
||||
const handleCommitPush = useCallback(async (message: string) => {
|
||||
setShowCommitDialog(false)
|
||||
try {
|
||||
await savePending()
|
||||
const remoteStatus = await resolveRemoteStatus()
|
||||
const nextCommitMode = commitModeFromRemoteStatus(remoteStatus)
|
||||
const result = await executeCommitAction(vaultPath, message, nextCommitMode)
|
||||
const runAutomaticCheckpoint = useAutomaticCheckpointAction({
|
||||
checkpointInFlightRef,
|
||||
savePending,
|
||||
loadModifiedFiles,
|
||||
resolveRemoteStatus,
|
||||
setToastMessage,
|
||||
onPushRejected,
|
||||
vaultPath,
|
||||
})
|
||||
|
||||
trackEvent('commit_made')
|
||||
setToastMessage(commitToastMessage(result))
|
||||
if (isPushRejected(result)) {
|
||||
onPushRejected?.()
|
||||
}
|
||||
|
||||
await loadModifiedFiles()
|
||||
await resolveRemoteStatus()
|
||||
} catch (err) {
|
||||
console.error('Commit failed:', err)
|
||||
setToastMessage(`Commit failed: ${formatCommitError(err)}`)
|
||||
}
|
||||
}, [loadModifiedFiles, onPushRejected, resolveRemoteStatus, savePending, setToastMessage, vaultPath])
|
||||
const handleCommitPush = useManualCommitPushAction({
|
||||
checkpointInFlightRef,
|
||||
savePending,
|
||||
loadModifiedFiles,
|
||||
resolveRemoteStatus,
|
||||
setToastMessage,
|
||||
onPushRejected,
|
||||
vaultPath,
|
||||
setShowCommitDialog,
|
||||
})
|
||||
|
||||
const closeCommitDialog = useCallback(() => setShowCommitDialog(false), [])
|
||||
|
||||
return { showCommitDialog, commitMode, openCommitDialog, handleCommitPush, closeCommitDialog }
|
||||
return { showCommitDialog, commitMode, openCommitDialog, handleCommitPush, closeCommitDialog, runAutomaticCheckpoint }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user