diff --git a/server/routes/git.js b/server/routes/git.js index 2214d90..18517fc 100755 --- a/server/routes/git.js +++ b/server/routes/git.js @@ -420,6 +420,53 @@ router.post('/commit', async (req, res) => { } }); +// Revert latest local commit (keeps changes staged) +router.post('/revert-local-commit', async (req, res) => { + const { project } = req.body; + + if (!project) { + return res.status(400).json({ error: 'Project name is required' }); + } + + try { + const projectPath = await getActualProjectPath(project); + await validateGitRepository(projectPath); + + try { + await spawnAsync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: projectPath }); + } catch (error) { + return res.status(400).json({ + error: 'No local commit to revert', + details: 'This repository has no commit yet.', + }); + } + + try { + // Soft reset rewinds one commit while preserving all file changes in the index. + await spawnAsync('git', ['reset', '--soft', 'HEAD~1'], { cwd: projectPath }); + } catch (error) { + const errorDetails = `${error.stderr || ''} ${error.message || ''}`; + const isInitialCommit = errorDetails.includes('HEAD~1') && + (errorDetails.includes('unknown revision') || errorDetails.includes('ambiguous argument')); + + if (!isInitialCommit) { + throw error; + } + + // Initial commit has no parent; deleting HEAD uncommits it and keeps files staged. + await spawnAsync('git', ['update-ref', '-d', 'HEAD'], { cwd: projectPath }); + } + + res.json({ + success: true, + output: 'Latest local commit reverted successfully. Changes were kept staged.', + }); + } catch (error) { + console.error('Git revert local commit error:', error); + res.status(500).json({ error: error.message }); + } +}); + // Get list of branches router.get('/branches', async (req, res) => { const { project } = req.query; diff --git a/src/components/git-panel/constants/constants.ts b/src/components/git-panel/constants/constants.ts index 5defa41..420f955 100644 --- a/src/components/git-panel/constants/constants.ts +++ b/src/components/git-panel/constants/constants.ts @@ -31,6 +31,7 @@ export const CONFIRMATION_TITLES: Record = { pull: 'Confirm Pull', push: 'Confirm Push', publish: 'Publish Branch', + revertLocalCommit: 'Revert Local Commit', }; export const CONFIRMATION_ACTION_LABELS: Record = { @@ -40,6 +41,7 @@ export const CONFIRMATION_ACTION_LABELS: Record = { pull: 'Pull', push: 'Push', publish: 'Publish', + revertLocalCommit: 'Revert Commit', }; export const CONFIRMATION_BUTTON_CLASSES: Record = { @@ -49,6 +51,7 @@ export const CONFIRMATION_BUTTON_CLASSES: Record = { pull: 'bg-green-600 hover:bg-green-700', push: 'bg-orange-600 hover:bg-orange-700', publish: 'bg-purple-600 hover:bg-purple-700', + revertLocalCommit: 'bg-yellow-600 hover:bg-yellow-700', }; export const CONFIRMATION_ICON_CONTAINER_CLASSES: Record = { @@ -58,6 +61,7 @@ export const CONFIRMATION_ICON_CONTAINER_CLASSES: Record = { @@ -67,4 +71,5 @@ export const CONFIRMATION_ICON_CLASSES: Record = { pull: 'text-yellow-600 dark:text-yellow-400', push: 'text-yellow-600 dark:text-yellow-400', publish: 'text-yellow-600 dark:text-yellow-400', + revertLocalCommit: 'text-yellow-600 dark:text-yellow-400', }; diff --git a/src/components/git-panel/hooks/useRevertLocalCommit.ts b/src/components/git-panel/hooks/useRevertLocalCommit.ts new file mode 100644 index 0000000..3c3ea91 --- /dev/null +++ b/src/components/git-panel/hooks/useRevertLocalCommit.ts @@ -0,0 +1,48 @@ +import { useCallback, useState } from 'react'; +import { authenticatedFetch } from '../../../utils/api'; +import type { GitOperationResponse } from '../types/types'; + +type UseRevertLocalCommitOptions = { + projectName: string | null; + onSuccess?: () => void; +}; + +async function readJson(response: Response): Promise { + return (await response.json()) as T; +} + +export function useRevertLocalCommit({ projectName, onSuccess }: UseRevertLocalCommitOptions) { + const [isRevertingLocalCommit, setIsRevertingLocalCommit] = useState(false); + + const revertLatestLocalCommit = useCallback(async () => { + if (!projectName) { + return; + } + + setIsRevertingLocalCommit(true); + try { + const response = await authenticatedFetch('/api/git/revert-local-commit', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ project: projectName }), + }); + const data = await readJson(response); + + if (!data.success) { + console.error('Revert local commit failed:', data.error || data.details || 'Unknown error'); + return; + } + + onSuccess?.(); + } catch (error) { + console.error('Error reverting local commit:', error); + } finally { + setIsRevertingLocalCommit(false); + } + }, [onSuccess, projectName]); + + return { + isRevertingLocalCommit, + revertLatestLocalCommit, + }; +} diff --git a/src/components/git-panel/types/types.ts b/src/components/git-panel/types/types.ts index 452f779..c8188e9 100644 --- a/src/components/git-panel/types/types.ts +++ b/src/components/git-panel/types/types.ts @@ -3,7 +3,7 @@ import type { Project } from '../../../types/app'; export type GitPanelView = 'changes' | 'history'; export type FileStatusCode = 'M' | 'A' | 'D' | 'U'; export type GitStatusFileGroup = 'modified' | 'added' | 'deleted' | 'untracked'; -export type ConfirmActionType = 'discard' | 'delete' | 'commit' | 'pull' | 'push' | 'publish'; +export type ConfirmActionType = 'discard' | 'delete' | 'commit' | 'pull' | 'push' | 'publish' | 'revertLocalCommit'; export type FileDiffInfo = { old_string: string; diff --git a/src/components/git-panel/view/GitPanel.tsx b/src/components/git-panel/view/GitPanel.tsx index c08c284..d670f65 100644 --- a/src/components/git-panel/view/GitPanel.tsx +++ b/src/components/git-panel/view/GitPanel.tsx @@ -1,5 +1,6 @@ import { useCallback, useState } from 'react'; import { useGitPanelController } from '../hooks/useGitPanelController'; +import { useRevertLocalCommit } from '../hooks/useRevertLocalCommit'; import type { ConfirmationRequest, GitPanelProps, GitPanelView } from '../types/types'; import ChangesView from '../view/changes/ChangesView'; import HistoryView from '../view/history/HistoryView'; @@ -49,6 +50,11 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen onFileOpen, }); + const { isRevertingLocalCommit, revertLatestLocalCommit } = useRevertLocalCommit({ + projectName: selectedProject?.name ?? null, + onSuccess: refreshAll, + }); + const executeConfirmedAction = useCallback(async () => { if (!confirmAction) { return; @@ -85,7 +91,9 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen isPulling={isPulling} isPushing={isPushing} isPublishing={isPublishing} + isRevertingLocalCommit={isRevertingLocalCommit} onRefresh={refreshAll} + onRevertLocalCommit={revertLatestLocalCommit} onSwitchBranch={switchBranch} onCreateBranch={createBranch} onFetch={handleFetch} diff --git a/src/components/git-panel/view/GitPanelHeader.tsx b/src/components/git-panel/view/GitPanelHeader.tsx index 78b8be0..2710d4b 100644 --- a/src/components/git-panel/view/GitPanelHeader.tsx +++ b/src/components/git-panel/view/GitPanelHeader.tsx @@ -1,4 +1,4 @@ -import { Check, ChevronDown, Download, GitBranch, Plus, RefreshCw, Upload } from 'lucide-react'; +import { Check, ChevronDown, Download, GitBranch, Plus, RefreshCw, RotateCcw, Upload } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; import type { ConfirmationRequest, GitRemoteStatus } from '../types/types'; import NewBranchModal from './modals/NewBranchModal'; @@ -14,7 +14,9 @@ type GitPanelHeaderProps = { isPulling: boolean; isPushing: boolean; isPublishing: boolean; + isRevertingLocalCommit: boolean; onRefresh: () => void; + onRevertLocalCommit: () => Promise; onSwitchBranch: (branchName: string) => Promise; onCreateBranch: (branchName: string) => Promise; onFetch: () => Promise; @@ -35,7 +37,9 @@ export default function GitPanelHeader({ isPulling, isPushing, isPublishing, + isRevertingLocalCommit, onRefresh, + onRevertLocalCommit, onSwitchBranch, onCreateBranch, onFetch, @@ -88,6 +92,14 @@ export default function GitPanelHeader({ }); }; + const requestRevertLocalCommitConfirmation = () => { + onRequestConfirmation({ + type: 'revertLocalCommit', + message: 'Revert the latest local commit? This removes the commit but keeps its changes staged.', + onConfirm: onRevertLocalCommit, + }); + }; + const handleSwitchBranch = async (branchName: string) => { try { const success = await onSwitchBranch(branchName); @@ -240,6 +252,17 @@ export default function GitPanelHeader({ )} + +