feat(git): add revert latest local commit action in git panel

Add a complete revert-local-commit flow so users can undo the most recent
local commit directly from the Git header, placed before the refresh icon.

Backend
- add POST /api/git/revert-local-commit endpoint in server/routes/git.js
- validate project input and repository state before executing git operations
- revert latest commit with `git reset --soft HEAD~1` to keep changes staged
- handle initial-commit edge case by deleting HEAD ref when no parent exists
- return clear success and error responses for UI consumption

Frontend
- add useRevertLocalCommit hook to encapsulate API call and loading state
- wire hook into GitPanel and refresh git data after successful revert
- add new toolbar action in GitPanelHeader before refresh icon
- route action through existing confirmation modal flow
- disable action while request is in flight and show activity indicator

Shared UI and typing updates
- extend ConfirmActionType with `revertLocalCommit`
- add confirmation title, label, and style mappings for new action
- render RotateCcw icon for revert action in ConfirmActionModal

Result
- users can safely undo the latest local commit from the UI
- reverted commit changes remain staged for immediate recommit/edit workflows
This commit is contained in:
Haileyesus
2026-03-10 22:37:25 +03:00
parent d508b1a4fd
commit 52d4671504
7 changed files with 138 additions and 3 deletions

View File

@@ -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;

View File

@@ -31,6 +31,7 @@ export const CONFIRMATION_TITLES: Record<ConfirmActionType, string> = {
pull: 'Confirm Pull',
push: 'Confirm Push',
publish: 'Publish Branch',
revertLocalCommit: 'Revert Local Commit',
};
export const CONFIRMATION_ACTION_LABELS: Record<ConfirmActionType, string> = {
@@ -40,6 +41,7 @@ export const CONFIRMATION_ACTION_LABELS: Record<ConfirmActionType, string> = {
pull: 'Pull',
push: 'Push',
publish: 'Publish',
revertLocalCommit: 'Revert Commit',
};
export const CONFIRMATION_BUTTON_CLASSES: Record<ConfirmActionType, string> = {
@@ -49,6 +51,7 @@ export const CONFIRMATION_BUTTON_CLASSES: Record<ConfirmActionType, string> = {
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<ConfirmActionType, string> = {
@@ -58,6 +61,7 @@ export const CONFIRMATION_ICON_CONTAINER_CLASSES: Record<ConfirmActionType, stri
pull: 'bg-yellow-100 dark:bg-yellow-900/30',
push: 'bg-yellow-100 dark:bg-yellow-900/30',
publish: 'bg-yellow-100 dark:bg-yellow-900/30',
revertLocalCommit: 'bg-yellow-100 dark:bg-yellow-900/30',
};
export const CONFIRMATION_ICON_CLASSES: Record<ConfirmActionType, string> = {
@@ -67,4 +71,5 @@ export const CONFIRMATION_ICON_CLASSES: Record<ConfirmActionType, string> = {
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',
};

View File

@@ -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<T>(response: Response): Promise<T> {
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<GitOperationResponse>(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,
};
}

View File

@@ -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;

View File

@@ -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}

View File

@@ -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<void>;
onSwitchBranch: (branchName: string) => Promise<boolean>;
onCreateBranch: (branchName: string) => Promise<boolean>;
onFetch: () => Promise<void>;
@@ -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({
</>
)}
<button
onClick={requestRevertLocalCommitConfirmation}
disabled={isRevertingLocalCommit}
className={`rounded-lg transition-colors hover:bg-accent disabled:opacity-50 ${isMobile ? 'p-1' : 'p-1.5'}`}
title="Revert latest local commit"
>
<RotateCcw
className={`text-muted-foreground ${isRevertingLocalCommit ? 'animate-pulse' : ''} ${isMobile ? 'h-3 w-3' : 'h-4 w-4'}`}
/>
</button>
<button
onClick={onRefresh}
disabled={isLoading}

View File

@@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { Check, Download, Trash2, Upload } from 'lucide-react';
import { Check, Download, RotateCcw, Trash2, Upload } from 'lucide-react';
import {
CONFIRMATION_ACTION_LABELS,
CONFIRMATION_BUTTON_CLASSES,
@@ -27,6 +27,10 @@ function renderConfirmActionIcon(actionType: ConfirmationRequest['type']) {
return <Download className="h-4 w-4" />;
}
if (actionType === 'revertLocalCommit') {
return <RotateCcw className="h-4 w-4" />;
}
return <Upload className="h-4 w-4" />;
}