From b0c8eff6c7e4282b914ef4cbf3ec401312f534f5 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Wed, 13 May 2026 14:12:23 +0200 Subject: [PATCH] fix: handle autogit author failures --- lara.lock | 3 + src/App.tsx | 1 + src/hooks/useAutoGit.test.ts | 129 +++++++++++++----------- src/hooks/useAutoGit.ts | 5 +- src/hooks/useCommitFlow.test.ts | 51 ++++++++++ src/hooks/useCommitFlow.ts | 61 ++++++++--- src/lib/locales/de-DE.json | 3 + src/lib/locales/en.json | 3 + src/lib/locales/es-419.json | 3 + src/lib/locales/es-ES.json | 3 + src/lib/locales/fr-FR.json | 3 + src/lib/locales/it-IT.json | 3 + src/lib/locales/ja-JP.json | 3 + src/lib/locales/ko-KR.json | 3 + src/lib/locales/pl-PL.json | 3 + src/lib/locales/pt-BR.json | 3 + src/lib/locales/pt-PT.json | 3 + src/lib/locales/ru-RU.json | 3 + src/lib/locales/vi.json | 3 + src/lib/locales/zh-CN.json | 3 + src/lib/locales/zh-TW.json | 3 + tests/smoke/autogit-checkpoints.spec.ts | 77 ++++++++++++-- 22 files changed, 286 insertions(+), 86 deletions(-) diff --git a/lara.lock b/lara.lock index 7f57592b..0face1e9 100644 --- a/lara.lock +++ b/lara.lock @@ -62,6 +62,9 @@ files: command.git.resolveConflicts: 871ac36968cfca3d2297a89b28b25dee command.git.viewChanges: b2699edf69d8e1031afce2b2f94e5c8d git.repository.select: 33fcf2b3ec4686d9cd06051c726d0ba2 + git.toast.autoGitFailed: 3e5d7e183a78e1f54c87ad0dba8a302b + git.toast.commitFailed: bb50986d29c30042bed1446f8adaa544 + git.toast.missingAuthor: 0d4e7a7d6b6a3f883146dd86c9cdca09 command.view.editorOnly: 8355e056b32086b9190d639be290dda8 command.view.editorNoteList: b9604b1609eb6f581cd9570dca72d3f0 command.view.fullLayout: 1cebdf839eee2b1713828731aaa3b507 diff --git a/src/App.tsx b/src/App.tsx index cd7ede42..3fa7de14 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -968,6 +968,7 @@ function App() { setToastMessage, onPushRejected: autoSync.handlePushRejected, automaticVaultPaths: activeGitRepositoryPaths, + locale: appLocale, manualVaultPath: gitSurfaces.commitRepositoryPath, vaultPath: resolvedPath, }) diff --git a/src/hooks/useAutoGit.test.ts b/src/hooks/useAutoGit.test.ts index 917be623..09a7435d 100644 --- a/src/hooks/useAutoGit.test.ts +++ b/src/hooks/useAutoGit.test.ts @@ -2,6 +2,55 @@ import { act, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAutoGit } from './useAutoGit' +type AutoGitOptions = Parameters[0] + +function defaultAutoGitOptions(onCheckpoint: AutoGitOptions['onCheckpoint']): AutoGitOptions { + return { + enabled: true, + idleThresholdSeconds: 1, + inactiveThresholdSeconds: 1, + isGitVault: true, + hasPendingChanges: true, + hasUnsavedChanges: false, + onCheckpoint, + } +} + +function renderAutoGit(overrides: Partial = {}) { + const onCheckpoint = overrides.onCheckpoint ?? vi.fn().mockResolvedValue(true) + const hook = renderHook(() => useAutoGit({ + ...defaultAutoGitOptions(onCheckpoint), + ...overrides, + onCheckpoint, + })) + + return { onCheckpoint, ...hook } +} + +async function advanceAutoGitBy(ms: number) { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms) + }) +} + +async function expectSingleCheckpointPerActivityBurst( + onCheckpoint: AutoGitOptions['onCheckpoint'], + recordActivity: () => void, +) { + await advanceAutoGitBy(1_000) + expect(onCheckpoint).toHaveBeenCalledTimes(1) + + await advanceAutoGitBy(3_000) + expect(onCheckpoint).toHaveBeenCalledTimes(1) + + act(() => { + recordActivity() + }) + + await advanceAutoGitBy(1_000) + expect(onCheckpoint).toHaveBeenCalledTimes(2) +} + describe('useAutoGit', () => { let hasFocus = true @@ -17,39 +66,23 @@ describe('useAutoGit', () => { }) it('triggers an idle checkpoint after the configured threshold', async () => { - const onCheckpoint = vi.fn().mockResolvedValue(true) - renderHook(() => useAutoGit({ - enabled: true, + const { onCheckpoint } = renderAutoGit({ idleThresholdSeconds: 3, inactiveThresholdSeconds: 2, - isGitVault: true, - hasPendingChanges: true, - hasUnsavedChanges: false, - onCheckpoint, - })) - - await act(async () => { - await vi.advanceTimersByTimeAsync(2_999) }) + + await advanceAutoGitBy(2_999) expect(onCheckpoint).not.toHaveBeenCalled() - await act(async () => { - await vi.advanceTimersByTimeAsync(1) - }) + await advanceAutoGitBy(1) expect(onCheckpoint).toHaveBeenCalledWith('idle') }) it('waits for the app to become inactive before triggering the inactive checkpoint', async () => { - const onCheckpoint = vi.fn().mockResolvedValue(true) - renderHook(() => useAutoGit({ - enabled: true, + const { onCheckpoint } = renderAutoGit({ idleThresholdSeconds: 10, inactiveThresholdSeconds: 2, - isGitVault: true, - hasPendingChanges: true, - hasUnsavedChanges: false, - onCheckpoint, - })) + }) hasFocus = false await act(async () => { @@ -61,53 +94,27 @@ describe('useAutoGit', () => { }) it('does not trigger while the editor still has unsaved changes', async () => { - const onCheckpoint = vi.fn().mockResolvedValue(true) - renderHook(() => useAutoGit({ - enabled: true, - idleThresholdSeconds: 1, - inactiveThresholdSeconds: 1, - isGitVault: true, - hasPendingChanges: true, + const { onCheckpoint } = renderAutoGit({ hasUnsavedChanges: true, - onCheckpoint, - })) - - await act(async () => { - await vi.advanceTimersByTimeAsync(2_000) }) + await advanceAutoGitBy(2_000) + expect(onCheckpoint).not.toHaveBeenCalled() }) it('only triggers once per activity burst until activity is recorded again', async () => { - const onCheckpoint = vi.fn().mockResolvedValue(true) - const { result } = renderHook(() => useAutoGit({ - enabled: true, - idleThresholdSeconds: 1, - inactiveThresholdSeconds: 1, - isGitVault: true, - hasPendingChanges: true, - hasUnsavedChanges: false, + const { onCheckpoint, result } = renderAutoGit() + await expectSingleCheckpointPerActivityBurst(onCheckpoint, result.current.recordActivity) + }) + + it('does not retry a rejected checkpoint for the same activity burst', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const onCheckpoint = vi.fn().mockRejectedValue(new Error('Author identity unknown')) + const { result } = renderAutoGit({ onCheckpoint, - })) - - await act(async () => { - await vi.advanceTimersByTimeAsync(1_000) }) - expect(onCheckpoint).toHaveBeenCalledTimes(1) - - await act(async () => { - await vi.advanceTimersByTimeAsync(3_000) - }) - expect(onCheckpoint).toHaveBeenCalledTimes(1) - - act(() => { - result.current.recordActivity() - }) - - await act(async () => { - await vi.advanceTimersByTimeAsync(1_000) - }) - expect(onCheckpoint).toHaveBeenCalledTimes(2) + await expectSingleCheckpointPerActivityBurst(onCheckpoint, result.current.recordActivity) + expect(warnSpy).toHaveBeenCalledWith('[git] Auto-commit failed:', expect.any(Error)) }) }) diff --git a/src/hooks/useAutoGit.ts b/src/hooks/useAutoGit.ts index c71cefe1..544c2e46 100644 --- a/src/hooks/useAutoGit.ts +++ b/src/hooks/useAutoGit.ts @@ -125,7 +125,10 @@ export function useAutoGit({ void onCheckpoint(trigger).then((didRun) => { if (didRun) markTriggerAsHandled(lastTriggeredRef.current, trigger, lastActivityAt) - }).catch((err) => console.warn('[git] Auto-commit failed:', err)) + }).catch((err) => { + markTriggerAsHandled(lastTriggeredRef.current, trigger, lastActivityAt) + console.warn('[git] Auto-commit failed:', err) + }) }) const updateAppActivity = useEffectEvent((active: boolean) => { diff --git a/src/hooks/useCommitFlow.test.ts b/src/hooks/useCommitFlow.test.ts index c38630f8..5305fddb 100644 --- a/src/hooks/useCommitFlow.test.ts +++ b/src/hooks/useCommitFlow.test.ts @@ -151,6 +151,26 @@ describe('useCommitFlow', () => { expect(setToastMessage).toHaveBeenCalledWith('Committed and pushed') }) + it('runAutomaticCheckpoint treats commit failures as handled after showing recovery feedback', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + mockInvokeFn.mockImplementation((command: string) => { + if (command === 'git_commit') return Promise.reject(new Error('Author identity unknown')) + throw new Error(`Unexpected command: ${command}`) + }) + const { result } = renderCommitFlow() + let didHandleCheckpoint = false + + await act(async () => { + didHandleCheckpoint = await result.current.runAutomaticCheckpoint() + }) + + expect(didHandleCheckpoint).toBe(true) + expect(setToastMessage).toHaveBeenCalledWith( + 'Set a Git author before AutoGit can commit. Run git config --global user.name "Your Name" and git config --global user.email you@example.com.', + ) + consoleSpy.mockRestore() + }) + it('runAutomaticCheckpoint commits and pushes all active repositories', async () => { const resolveRemoteStatusForVaultPath = vi.fn().mockResolvedValue({ branch: 'main', @@ -191,6 +211,37 @@ describe('useCommitFlow', () => { expect(setToastMessage).toHaveBeenCalledWith('AutoGit checkpointed 2 repositories') }) + it('runAutomaticCheckpoint treats multi-repository commit failures as handled', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const loadModifiedFilesForVaultPath = vi.fn((vaultPath: string) => Promise.resolve([{ + path: `${vaultPath}/note.md`, + relativePath: 'note.md', + status: 'modified', + }])) + mockInvokeFn.mockImplementation((command: string) => { + if (command === 'git_commit') return Promise.reject(new Error('Please tell me who you are')) + throw new Error(`Unexpected command: ${command}`) + }) + + const { result } = renderCommitFlow({ + automaticVaultPaths: ['/vault', '/work'], + loadModifiedFilesForVaultPath, + }) + let didHandleCheckpoint = false + + await act(async () => { + didHandleCheckpoint = await result.current.runAutomaticCheckpoint() + }) + + expect(didHandleCheckpoint).toBe(true) + expect(loadModifiedFilesForVaultPath).toHaveBeenCalledWith('/vault') + expect(loadModifiedFilesForVaultPath).toHaveBeenCalledWith('/work') + expect(setToastMessage).toHaveBeenCalledWith( + 'Set a Git author before AutoGit can commit. Run git config --global user.name "Your Name" and git config --global user.email you@example.com.', + ) + consoleSpy.mockRestore() + }) + it('runAutomaticCheckpoint retries push-only when local commits are already ahead', async () => { resolveRemoteStatusForVaultPath.mockResolvedValue({ branch: 'main', ahead: 2, behind: 0, hasRemote: true }) loadModifiedFilesForVaultPath.mockResolvedValue([]) diff --git a/src/hooks/useCommitFlow.ts b/src/hooks/useCommitFlow.ts index 31a86c28..02f08c57 100644 --- a/src/hooks/useCommitFlow.ts +++ b/src/hooks/useCommitFlow.ts @@ -1,9 +1,10 @@ -import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from 'react' import { invoke } from '@tauri-apps/api/core' import type { GitPushResult, GitRemoteStatus, ModifiedFile } from '../types' import { trackEvent } from '../lib/telemetry' import { isTauri, mockInvoke } from '../mock-tauri' import { generateAutomaticCommitMessage } from '../utils/automaticCommitMessage' +import { createTranslator, type AppLocale } from '../lib/i18n' export type CommitMode = 'push' | 'local' @@ -27,6 +28,7 @@ interface CommitFlowConfig { setToastMessage: (msg: string | null) => void onPushRejected?: () => void automaticVaultPaths?: string[] + locale?: AppLocale manualVaultPath?: string vaultPath: string } @@ -80,6 +82,7 @@ type FinalizeCheckpointConfig = Pick< CommitFlowConfig, 'loadModifiedFiles' | 'resolveRemoteStatusForVaultPath' | 'setToastMessage' | 'onPushRejected' > +type Translator = ReturnType interface FinalizeCheckpointArgs extends FinalizeCheckpointConfig { result: CommitResult @@ -132,10 +135,31 @@ function isPushRejected(result: CommitResult): boolean { return result.status === 'rejected' } -function formatCommitError(error: unknown): string { +function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error) } +function isMissingGitAuthorIdentityError(message: string): boolean { + const normalized = message.toLowerCase() + return normalized.includes('author identity unknown') + || normalized.includes('please tell me who you are') + || normalized.includes('unable to auto-detect email address') + || (normalized.includes('user.email') && normalized.includes('not set')) + || (normalized.includes('user.name') && normalized.includes('not set')) +} + +function formatCommitFailureToast(error: unknown, t: Translator): string { + const message = errorMessage(error) + if (isMissingGitAuthorIdentityError(message)) return t('git.toast.missingAuthor') + return t('git.toast.commitFailed', { error: message }) +} + +function formatAutoGitFailureToast(error: unknown, t: Translator): string { + const message = errorMessage(error) + if (isMissingGitAuthorIdentityError(message)) return t('git.toast.missingAuthor') + return t('git.toast.autoGitFailed', { error: message }) +} + function shouldRetryPush(remoteStatus: GitRemoteStatus | null): boolean { return remoteStatus?.hasRemote === true && remoteStatus.ahead > 0 } @@ -227,15 +251,15 @@ async function checkpointRepository( return { action, remoteStatus, result, status: 'executed', vaultPath } } -function multiRepositoryCheckpointToast(results: RepositoryCheckpointResult[]): string { +function multiRepositoryCheckpointToast(results: RepositoryCheckpointResult[], t: Translator): string { const executedCount = results.filter((result) => result.status === 'executed').length const failedCount = results.filter((result) => result.status === 'failed').length const rejectedCount = results.filter((result) => result.result && isPushRejected(result.result)).length if (executedCount === 0) { const firstError = results.find((result) => result.status === 'failed')?.error - return firstError - ? `AutoGit failed: ${formatCommitError(firstError)}` + return firstError !== undefined + ? formatAutoGitFailureToast(firstError, t) : 'Nothing to commit or push' } @@ -331,6 +355,7 @@ async function checkpointRepositories( async function runMultipleRepositoryCheckpoint( targetVaultPaths: string[], config: AutomaticCheckpointRunConfig, + t: Translator, ): Promise { const results = await checkpointRepositories(targetVaultPaths, config) @@ -338,13 +363,13 @@ async function runMultipleRepositoryCheckpoint( config.onPushRejected?.() } - config.setToastMessage(multiRepositoryCheckpointToast(results)) + config.setToastMessage(multiRepositoryCheckpointToast(results, t)) await runCheckpointRefresh({ loadModifiedFiles: config.loadModifiedFiles, resolveRemoteStatusForVaultPath: config.resolveRemoteStatusForVaultPath, vaultPaths: targetVaultPaths, }) - return results.some((result) => result.status === 'executed') + return results.some((result) => result.status === 'executed' || result.status === 'failed') } function useAutomaticCheckpointAction({ @@ -357,8 +382,10 @@ function useAutomaticCheckpointAction({ onPushRejected, automaticVaultPaths, vaultPath, + t, }: CommitFlowConfig & { checkpointInFlightRef: MutableRefObject + t: Translator }) { return useCallback(async ({ savePendingBeforeCommit = false, @@ -380,13 +407,13 @@ function useAutomaticCheckpointAction({ setToastMessage, vaultPath, } - return targetVaultPaths.length === 1 + return await (targetVaultPaths.length === 1 ? runSingleRepositoryCheckpoint(targetVaultPaths[0], runConfig) - : runMultipleRepositoryCheckpoint(targetVaultPaths, runConfig) + : runMultipleRepositoryCheckpoint(targetVaultPaths, runConfig, t)) } catch (err) { console.error('Commit failed:', err) - setToastMessage(`Commit failed: ${formatCommitError(err)}`) - return false + setToastMessage(formatCommitFailureToast(err, t)) + return true } finally { checkpointInFlightRef.current = false } @@ -399,6 +426,7 @@ function useAutomaticCheckpointAction({ resolveRemoteStatusForVaultPath, savePending, setToastMessage, + t, vaultPath, ]) } @@ -413,6 +441,7 @@ function useManualCommitPushAction({ manualVaultPath, vaultPath, setShowCommitDialog, + t, }: Pick< CommitFlowConfig, | 'savePending' @@ -425,6 +454,7 @@ function useManualCommitPushAction({ > & { checkpointInFlightRef: MutableRefObject setShowCommitDialog: (open: boolean) => void + t: Translator }) { return useCallback(async (message: string) => { setShowCommitDialog(false) @@ -453,7 +483,7 @@ function useManualCommitPushAction({ }) } catch (err) { console.error('Commit failed:', err) - setToastMessage(`Commit failed: ${formatCommitError(err)}`) + setToastMessage(formatCommitFailureToast(err, t)) } finally { checkpointInFlightRef.current = false } @@ -466,6 +496,7 @@ function useManualCommitPushAction({ savePending, setShowCommitDialog, setToastMessage, + t, vaultPath, ]) } @@ -553,7 +584,7 @@ function useOpenCommitDialog({ setShowCommitDialog(true) } catch (err) { console.error('Commit dialog failed:', err) - setToastMessage(`Commit dialog failed: ${formatCommitError(err)}`) + setToastMessage(`Commit dialog failed: ${errorMessage(err)}`) } finally { dialogOpeningRef.current = false setDialogOpening(false) @@ -582,6 +613,7 @@ export function useCommitFlow({ setToastMessage, onPushRejected, automaticVaultPaths, + locale, manualVaultPath, vaultPath, }: CommitFlowConfig) { @@ -591,6 +623,7 @@ export function useCommitFlow({ const checkpointInFlightRef = useRef(false) const dialogOpeningRef = useRef(false) const commitModeVaultPathRef = useRef(null) + const t = useMemo(() => createTranslator(locale), [locale]) const openCommitDialog = useOpenCommitDialog({ dialogOpeningRef, @@ -616,6 +649,7 @@ export function useCommitFlow({ onPushRejected, automaticVaultPaths, vaultPath, + t, }) const handleCommitPush = useManualCommitPushAction({ @@ -628,6 +662,7 @@ export function useCommitFlow({ manualVaultPath, vaultPath, setShowCommitDialog, + t, }) useCommitModeRefresh({ commitModeVaultPathRef, diff --git a/src/lib/locales/de-DE.json b/src/lib/locales/de-DE.json index 52dbdb03..1d1a3a0e 100644 --- a/src/lib/locales/de-DE.json +++ b/src/lib/locales/de-DE.json @@ -60,6 +60,9 @@ "command.git.resolveConflicts": "Konflikte auflösen", "command.git.viewChanges": "Ausstehende Änderungen anzeigen", "git.repository.select": "Repository", + "git.toast.autoGitFailed": "AutoGit ist fehlgeschlagen: {error}", + "git.toast.commitFailed": "Commit fehlgeschlagen: {error}", + "git.toast.missingAuthor": "Legen Sie einen Git-Autor fest, bevor AutoGit einen Commit durchführen kann. Führen Sie git config --global user.name \"Ihr Name\" und git config --global user.email you@example.com aus.", "command.view.editorOnly": "Nur Editor", "command.view.editorNoteList": "Editor + Notizenliste", "command.view.fullLayout": "Vollständiges Layout", diff --git a/src/lib/locales/en.json b/src/lib/locales/en.json index dd57f371..05fc3be7 100644 --- a/src/lib/locales/en.json +++ b/src/lib/locales/en.json @@ -60,6 +60,9 @@ "command.git.resolveConflicts": "Resolve Conflicts", "command.git.viewChanges": "View Pending Changes", "git.repository.select": "Repository", + "git.toast.autoGitFailed": "AutoGit failed: {error}", + "git.toast.commitFailed": "Commit failed: {error}", + "git.toast.missingAuthor": "Set a Git author before AutoGit can commit. Run git config --global user.name \"Your Name\" and git config --global user.email you@example.com.", "command.view.editorOnly": "Editor Only", "command.view.editorNoteList": "Editor + Note List", "command.view.fullLayout": "Full Layout", diff --git a/src/lib/locales/es-419.json b/src/lib/locales/es-419.json index 3088617e..0050f508 100644 --- a/src/lib/locales/es-419.json +++ b/src/lib/locales/es-419.json @@ -60,6 +60,9 @@ "command.git.resolveConflicts": "Resolver conflictos", "command.git.viewChanges": "Ver cambios pendientes", "git.repository.select": "Repositorio", + "git.toast.autoGitFailed": "Error de AutoGit: {error}", + "git.toast.commitFailed": "Error al confirmar: {error}", + "git.toast.missingAuthor": "Establezca un autor de Git antes de que AutoGit pueda realizar la confirmación. Ejecute git config --global user.name \"Su nombre\" y git config --global user.email you@example.com.", "command.view.editorOnly": "Solo editor", "command.view.editorNoteList": "Editor + Lista de notas", "command.view.fullLayout": "Diseño completo", diff --git a/src/lib/locales/es-ES.json b/src/lib/locales/es-ES.json index 81f88e39..f77d74d0 100644 --- a/src/lib/locales/es-ES.json +++ b/src/lib/locales/es-ES.json @@ -60,6 +60,9 @@ "command.git.resolveConflicts": "Resolver conflictos", "command.git.viewChanges": "Ver cambios pendientes", "git.repository.select": "Repositorio", + "git.toast.autoGitFailed": "Error de AutoGit: {error}", + "git.toast.commitFailed": "Error al confirmar: {error}", + "git.toast.missingAuthor": "Establezca un autor de Git antes de que AutoGit pueda realizar la confirmación. Ejecute git config --global user.name \"Su nombre\" y git config --global user.email you@example.com.", "command.view.editorOnly": "Solo editor", "command.view.editorNoteList": "Editor + Lista de notas", "command.view.fullLayout": "Diseño completo", diff --git a/src/lib/locales/fr-FR.json b/src/lib/locales/fr-FR.json index 3f81b4e3..e1235260 100644 --- a/src/lib/locales/fr-FR.json +++ b/src/lib/locales/fr-FR.json @@ -60,6 +60,9 @@ "command.git.resolveConflicts": "Résoudre les conflits", "command.git.viewChanges": "Afficher les modifications en attente", "git.repository.select": "Dépôt", + "git.toast.autoGitFailed": "Échec d'AutoGit : {error}", + "git.toast.commitFailed": "Échec de la validation : {error}", + "git.toast.missingAuthor": "Définissez un auteur Git avant qu'AutoGit puisse valider. Exécutez git config --global user.name \"Votre nom\" et git config --global user.email you@example.com.", "command.view.editorOnly": "Éditeur uniquement", "command.view.editorNoteList": "Éditeur + liste de notes", "command.view.fullLayout": "Mise en page complète", diff --git a/src/lib/locales/it-IT.json b/src/lib/locales/it-IT.json index a8a5d740..61f16b6d 100644 --- a/src/lib/locales/it-IT.json +++ b/src/lib/locales/it-IT.json @@ -60,6 +60,9 @@ "command.git.resolveConflicts": "Risolvi conflitti", "command.git.viewChanges": "Visualizza modifiche in sospeso", "git.repository.select": "Repository", + "git.toast.autoGitFailed": "AutoGit non riuscito: {error}", + "git.toast.commitFailed": "Commit non riuscito: {error}", + "git.toast.missingAuthor": "Imposta un autore Git prima che AutoGit possa eseguire il commit. Esegui git config --global user.name \"Il tuo nome\" e git config --global user.email you@example.com.", "command.view.editorOnly": "Solo editor", "command.view.editorNoteList": "Editor + Elenco note", "command.view.fullLayout": "Layout completo", diff --git a/src/lib/locales/ja-JP.json b/src/lib/locales/ja-JP.json index 896b942a..bca264f7 100644 --- a/src/lib/locales/ja-JP.json +++ b/src/lib/locales/ja-JP.json @@ -60,6 +60,9 @@ "command.git.resolveConflicts": "競合を解決", "command.git.viewChanges": "保留中の変更を表示", "git.repository.select": "リポジトリ", + "git.toast.autoGitFailed": "AutoGitに失敗しました:{error}", + "git.toast.commitFailed": "コミットに失敗しました:{error}", + "git.toast.missingAuthor": "AutoGitがコミットできるようになる前に、Gitの作成者を設定してください。git config --global user.name \"Your Name\" と git config --global user.email you@example.com を実行してください。", "command.view.editorOnly": "エディターのみ", "command.view.editorNoteList": "エディター + メモリスト", "command.view.fullLayout": "フルレイアウト", diff --git a/src/lib/locales/ko-KR.json b/src/lib/locales/ko-KR.json index 6f7c4523..375446d4 100644 --- a/src/lib/locales/ko-KR.json +++ b/src/lib/locales/ko-KR.json @@ -60,6 +60,9 @@ "command.git.resolveConflicts": "충돌 해결", "command.git.viewChanges": "보류 중인 변경 사항 보기", "git.repository.select": "저장소", + "git.toast.autoGitFailed": "AutoGit 실패: {error}", + "git.toast.commitFailed": "커밋 실패: {error}", + "git.toast.missingAuthor": "AutoGit이 커밋하기 전에 Git 작성자를 설정하세요. git config --global user.name \"Your Name\" 및 git config --global user.email you@example.com을 실행하세요.", "command.view.editorOnly": "편집기만", "command.view.editorNoteList": "편집기 + 노트 목록", "command.view.fullLayout": "전체 레이아웃", diff --git a/src/lib/locales/pl-PL.json b/src/lib/locales/pl-PL.json index 8c31364b..4a55d167 100644 --- a/src/lib/locales/pl-PL.json +++ b/src/lib/locales/pl-PL.json @@ -60,6 +60,9 @@ "command.git.resolveConflicts": "Rozwiąż konflikty", "command.git.viewChanges": "Pokaż oczekujące zmiany", "git.repository.select": "Repozytorium", + "git.toast.autoGitFailed": "Błąd AutoGit: {error}", + "git.toast.commitFailed": "Zatwierdzenie nie powiodło się: {error}", + "git.toast.missingAuthor": "Ustaw autora Git, zanim AutoGit będzie mógł wykonać commit. Uruchom git config --global user.name \"Twoje imię\" i git config --global user.email you@example.com.", "command.view.editorOnly": "Tylko edytor", "command.view.editorNoteList": "Edytor + lista notatek", "command.view.fullLayout": "Pełny układ", diff --git a/src/lib/locales/pt-BR.json b/src/lib/locales/pt-BR.json index 7c87c074..e24a19ef 100644 --- a/src/lib/locales/pt-BR.json +++ b/src/lib/locales/pt-BR.json @@ -60,6 +60,9 @@ "command.git.resolveConflicts": "Resolver conflitos", "command.git.viewChanges": "Exibir alterações pendentes", "git.repository.select": "Repositório", + "git.toast.autoGitFailed": "Falha no AutoGit: {error}", + "git.toast.commitFailed": "Falha no commit: {error}", + "git.toast.missingAuthor": "Defina um autor Git antes que o AutoGit possa fazer o commit. Execute git config --global user.name \"Seu Nome\" e git config --global user.email you@example.com.", "command.view.editorOnly": "Somente editor", "command.view.editorNoteList": "Editor + Lista de notas", "command.view.fullLayout": "Layout completo", diff --git a/src/lib/locales/pt-PT.json b/src/lib/locales/pt-PT.json index bb982900..bdfde18d 100644 --- a/src/lib/locales/pt-PT.json +++ b/src/lib/locales/pt-PT.json @@ -60,6 +60,9 @@ "command.git.resolveConflicts": "Resolver conflitos", "command.git.viewChanges": "Ver alterações pendentes", "git.repository.select": "Repositório", + "git.toast.autoGitFailed": "Falha no AutoGit: {error}", + "git.toast.commitFailed": "Falha no commit: {error}", + "git.toast.missingAuthor": "Defina um autor Git antes de o AutoGit poder fazer o commit. Execute git config --global user.name \"O seu nome\" e git config --global user.email you@example.com.", "command.view.editorOnly": "Apenas editor", "command.view.editorNoteList": "Editor + Lista de notas", "command.view.fullLayout": "Disposição completa", diff --git a/src/lib/locales/ru-RU.json b/src/lib/locales/ru-RU.json index f98dd8a5..9bb6ffe3 100644 --- a/src/lib/locales/ru-RU.json +++ b/src/lib/locales/ru-RU.json @@ -60,6 +60,9 @@ "command.git.resolveConflicts": "Разрешить конфликты", "command.git.viewChanges": "Просмотреть ожидающие изменения", "git.repository.select": "Репозиторий", + "git.toast.autoGitFailed": "Ошибка AutoGit: {error}", + "git.toast.commitFailed": "Ошибка коммита: {error}", + "git.toast.missingAuthor": "Укажите автора Git, прежде чем AutoGit сможет выполнить коммит. Выполните git config --global user.name \"Your Name\" и git config --global user.email you@example.com.", "command.view.editorOnly": "Только редактор", "command.view.editorNoteList": "Редактор + список заметок", "command.view.fullLayout": "Полный макет", diff --git a/src/lib/locales/vi.json b/src/lib/locales/vi.json index 715005d1..2097f5d8 100644 --- a/src/lib/locales/vi.json +++ b/src/lib/locales/vi.json @@ -60,6 +60,9 @@ "command.git.resolveConflicts": "Giải quyết xung đột", "command.git.viewChanges": "Xem các thay đổi đang chờ", "git.repository.select": "Kho lưu trữ", + "git.toast.autoGitFailed": "AutoGit không thành công: {error}", + "git.toast.commitFailed": "Gửi không thành công: {error}", + "git.toast.missingAuthor": "Đặt tác giả Git trước khi AutoGit có thể commit. Chạy git config --global user.name \"Tên của bạn\" và git config --global user.email you@example.com.", "command.view.editorOnly": "Chỉ trình soạn thảo", "command.view.editorNoteList": "Trình soạn thảo + danh sách ghi chú", "command.view.fullLayout": "Bố cục đầy đủ", diff --git a/src/lib/locales/zh-CN.json b/src/lib/locales/zh-CN.json index b585d7ae..10484f7d 100644 --- a/src/lib/locales/zh-CN.json +++ b/src/lib/locales/zh-CN.json @@ -60,6 +60,9 @@ "command.git.resolveConflicts": "解决冲突", "command.git.viewChanges": "查看待处理更改", "git.repository.select": "仓库", + "git.toast.autoGitFailed": "AutoGit 失败:{error}", + "git.toast.commitFailed": "提交失败:{error}", + "git.toast.missingAuthor": "设置 Git 作者后,AutoGit 才能提交。运行 git config --global user.name \"Your Name\" 和 git config --global user.email you@example.com。", "command.view.editorOnly": "仅编辑器", "command.view.editorNoteList": "编辑器 + 笔记列表", "command.view.fullLayout": "完整布局", diff --git a/src/lib/locales/zh-TW.json b/src/lib/locales/zh-TW.json index e3dde058..c04c8f1f 100644 --- a/src/lib/locales/zh-TW.json +++ b/src/lib/locales/zh-TW.json @@ -60,6 +60,9 @@ "command.git.resolveConflicts": "解決衝突", "command.git.viewChanges": "檢視待處理更改", "git.repository.select": "儲存庫", + "git.toast.autoGitFailed": "AutoGit 失敗:{error}", + "git.toast.commitFailed": "提交失敗:{error}", + "git.toast.missingAuthor": "設定 Git 作者後,AutoGit 才能提交。執行 git config --global user.name \"Your Name\" 和 git config --global user.email you@example.com。", "command.view.editorOnly": "僅編輯器", "command.view.editorNoteList": "編輯器 + 筆記列表", "command.view.fullLayout": "完整佈局", diff --git a/tests/smoke/autogit-checkpoints.spec.ts b/tests/smoke/autogit-checkpoints.spec.ts index 1918877a..97bc1658 100644 --- a/tests/smoke/autogit-checkpoints.spec.ts +++ b/tests/smoke/autogit-checkpoints.spec.ts @@ -7,16 +7,19 @@ type MockHandler = (args?: Record) => unknown function installAutoGitMocks() { type BrowserWindow = Window & typeof globalThis & { __getDirtyPathCount?: () => number + __gitCommitAttempts?: number __gitCommitMessages?: string[] __gitPushCalls?: number __mockHandlers?: Record __setMockAppActive?: (active: boolean) => void + __setMockGitCommitFailure?: (message: string | null) => void } const browserWindow = window as BrowserWindow const dirtyPaths = new Set() let appActive = true let ahead = 0 + let gitCommitFailure: string | null = null const createModifiedFiles = () => [...dirtyPaths].map((path) => ({ path, @@ -40,6 +43,11 @@ function installAutoGitMocks() { handlers.get_modified_files = () => createModifiedFiles() handlers.git_commit = (args?: Record) => { + browserWindow.__gitCommitAttempts = (browserWindow.__gitCommitAttempts ?? 0) + 1 + if (gitCommitFailure) { + throw new Error(gitCommitFailure) + } + const message = typeof args?.message === 'string' ? args.message : '' browserWindow.__gitCommitMessages?.push(message) dirtyPaths.clear() @@ -84,7 +92,11 @@ function installAutoGitMocks() { browserWindow.__setMockAppActive = (active: boolean) => { appActive = active } + browserWindow.__setMockGitCommitFailure = (message: string | null) => { + gitCommitFailure = message + } browserWindow.__getDirtyPathCount = () => dirtyPaths.size + browserWindow.__gitCommitAttempts = 0 browserWindow.__gitCommitMessages = [] browserWindow.__gitPushCalls = 0 @@ -133,6 +145,12 @@ async function expectCommitMessageCount(page: Page, expectedCount: number) { ).toBe(expectedCount) } +async function expectCommitAttemptCount(page: Page, expectedCount: number) { + await expect.poll(async () => + page.evaluate(() => (window as Window & { __gitCommitAttempts?: number }).__gitCommitAttempts ?? 0), + ).toBe(expectedCount) +} + async function expectCommitMessage(page: Page, index: number, expectedMessage: string) { await expect.poll(async () => page.evaluate( @@ -166,13 +184,21 @@ async function setMockAppActive(page: Page, active: boolean) { }, active) } -async function triggerQuickCommit(page: Page) { - const commitButton = page.getByTestId('status-commit-push') - await commitButton.focus() - await page.keyboard.press('Enter') +async function setMockGitCommitFailure(page: Page, message: string | null) { + await page.evaluate((failureMessage) => { + ;(window as Window & { + __setMockGitCommitFailure?: (message: string | null) => void + }).__setMockGitCommitFailure?.(failureMessage) + }, message) } -test('@smoke AutoGit checkpoints on idle and inactive, and the bottom bar reuses the same message', async ({ page }) => { +async function triggerQuickCommit(page: Page) { + const commitButton = page.getByTestId('status-commit-push') + await expect(commitButton).toBeEnabled({ timeout: 5_000 }) + await commitButton.click() +} + +test('@smoke AutoGit checkpoints on idle, and the bottom bar reuses the same message', async ({ page }) => { await page.addInitScript(installAutoGitMocks) await page.goto('/') await page.waitForLoadState('networkidle') @@ -183,14 +209,43 @@ test('@smoke AutoGit checkpoints on idle and inactive, and the bottom bar reuses await seedSavedChange(page) await expectCheckpoint(page, 1) - await seedSavedChange(page) - await setMockAppActive(page, false) - await expectCheckpoint(page, 2) - - await setMockAppActive(page, true) await seedSavedChange(page) await triggerQuickCommit(page) - await expectCheckpoint(page, 3) + await expectCheckpoint(page, 2) await expect(page.locator('.fixed.bottom-8')).toContainText('Committed and pushed', { timeout: 5_000 }) }) + +test('AutoGit checkpoints when the app is inactive', async ({ page }) => { + await page.addInitScript(installAutoGitMocks) + await page.goto('/') + await page.waitForLoadState('networkidle') + + await openAutoGitSettings(page) + await openFirstNote(page) + await setMockAppActive(page, false) + + await seedSavedChange(page) + await expectCheckpoint(page, 1) +}) + +test('AutoGit does not retry a failed checkpoint until the next saved change', async ({ page }) => { + await page.addInitScript(installAutoGitMocks) + await page.goto('/') + await page.waitForLoadState('networkidle') + + await openAutoGitSettings(page) + await openFirstNote(page) + await setMockGitCommitFailure(page, 'Author identity unknown') + + await seedSavedChange(page) + await expectCommitAttemptCount(page, 1) + await expectDirtyPathCount(page, 1) + await expect(page.locator('.fixed.bottom-8')).toContainText('Set a Git author before AutoGit can commit', { timeout: 5_000 }) + + await page.waitForTimeout(3_500) + await expectCommitAttemptCount(page, 1) + + await seedSavedChange(page) + await expectCommitAttemptCount(page, 2) +})