feat: add discard changes functionality for worktrees

- Introduced a new POST /discard-changes endpoint to discard all uncommitted changes in a worktree, including resetting staged changes, discarding modifications to tracked files, and removing untracked files.
- Implemented a corresponding handler in the UI to confirm and execute the discard operation, enhancing user control over worktree changes.
- Added a ViewWorktreeChangesDialog component to display changes in the worktree, improving the user experience for managing worktree states.
- Updated the WorktreePanel and WorktreeActionsDropdown components to integrate the new functionality, allowing users to view and discard changes directly from the UI.

This update streamlines the management of worktree changes, providing users with essential tools for version control.
This commit is contained in:
webdevcody
2026-01-19 17:37:13 -05:00
parent 43481c2bab
commit b039b745be
10 changed files with 363 additions and 1 deletions

View File

@@ -48,6 +48,7 @@ import {
createDeleteInitScriptHandler,
createRunInitScriptHandler,
} from './routes/init-script.js';
import { createDiscardChangesHandler } from './routes/discard-changes.js';
import type { SettingsService } from '../../services/settings-service.js';
export function createWorktreeRoutes(
@@ -148,5 +149,13 @@ export function createWorktreeRoutes(
createRunInitScriptHandler(events)
);
// Discard changes route
router.post(
'/discard-changes',
validatePathParams('worktreePath'),
requireGitRepoOnly,
createDiscardChangesHandler()
);
return router;
}

View File

@@ -0,0 +1,112 @@
/**
* POST /discard-changes endpoint - Discard all uncommitted changes in a worktree
*
* This performs a destructive operation that:
* 1. Resets staged changes (git reset HEAD)
* 2. Discards modified tracked files (git checkout .)
* 3. Removes untracked files and directories (git clean -fd)
*
* Note: Git repository validation (isGitRepo) is handled by
* the requireGitRepoOnly middleware in index.ts
*/
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec);
export function createDiscardChangesHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath } = req.body as {
worktreePath: string;
};
if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath required',
});
return;
}
// Check for uncommitted changes first
const { stdout: status } = await execAsync('git status --porcelain', {
cwd: worktreePath,
});
if (!status.trim()) {
res.json({
success: true,
result: {
discarded: false,
message: 'No changes to discard',
},
});
return;
}
// Count the files that will be affected
const lines = status.trim().split('\n').filter(Boolean);
const fileCount = lines.length;
// Get branch name before discarding
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
cwd: worktreePath,
});
const branchName = branchOutput.trim();
// Discard all changes:
// 1. Reset any staged changes
await execAsync('git reset HEAD', { cwd: worktreePath }).catch(() => {
// Ignore errors - might fail if there's nothing staged
});
// 2. Discard changes in tracked files
await execAsync('git checkout .', { cwd: worktreePath }).catch(() => {
// Ignore errors - might fail if there are no tracked changes
});
// 3. Remove untracked files and directories
await execAsync('git clean -fd', { cwd: worktreePath }).catch(() => {
// Ignore errors - might fail if there are no untracked files
});
// Verify all changes were discarded
const { stdout: finalStatus } = await execAsync('git status --porcelain', {
cwd: worktreePath,
});
if (finalStatus.trim()) {
// Some changes couldn't be discarded (possibly ignored files or permission issues)
const remainingCount = finalStatus.trim().split('\n').filter(Boolean).length;
res.json({
success: true,
result: {
discarded: true,
filesDiscarded: fileCount - remainingCount,
filesRemaining: remainingCount,
branch: branchName,
message: `Discarded ${fileCount - remainingCount} files, ${remainingCount} files could not be removed`,
},
});
} else {
res.json({
success: true,
result: {
discarded: true,
filesDiscarded: fileCount,
filesRemaining: 0,
branch: branchName,
message: `Discarded ${fileCount} ${fileCount === 1 ? 'file' : 'files'}`,
},
});
}
} catch (error) {
logError(error, 'Discard changes failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -8,3 +8,4 @@ export { EditFeatureDialog } from './edit-feature-dialog';
export { FollowUpDialog, type FollowUpHistoryEntry } from './follow-up-dialog';
export { PlanApprovalDialog } from './plan-approval-dialog';
export { MassEditDialog } from './mass-edit-dialog';
export { ViewWorktreeChangesDialog } from './view-worktree-changes-dialog';

View File

@@ -0,0 +1,68 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { FileText } from 'lucide-react';
import { GitDiffPanel } from '@/components/ui/git-diff-panel';
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
interface ViewWorktreeChangesDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
projectPath: string;
}
export function ViewWorktreeChangesDialog({
open,
onOpenChange,
worktree,
projectPath,
}: ViewWorktreeChangesDialogProps) {
if (!worktree) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[900px] sm:max-h-[100vh] sm:h-auto sm:rounded-xl rounded-none flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileText className="w-5 h-5" />
View Changes
</DialogTitle>
<DialogDescription>
Changes in the{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code> worktree.
{worktree.changedFilesCount !== undefined && worktree.changedFilesCount > 0 && (
<span className="ml-1">
({worktree.changedFilesCount} file
{worktree.changedFilesCount > 1 ? 's' : ''} changed)
</span>
)}
</DialogDescription>
</DialogHeader>
<div className="flex-1 sm:min-h-[600px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible -mx-6 -mb-6">
<div className="h-full px-6 pb-6">
<GitDiffPanel
projectPath={projectPath}
featureId={worktree.branch}
useWorktrees={true}
compact={false}
className="mt-4"
/>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -25,11 +25,13 @@ import {
AlertCircle,
RefreshCw,
Copy,
Eye,
ScrollText,
Terminal,
SquarePlus,
SplitSquareHorizontal,
Zap,
Undo2,
} from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
@@ -65,6 +67,8 @@ interface WorktreeActionsDropdownProps {
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
onViewChanges: (worktree: WorktreeInfo) => void;
onDiscardChanges: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
@@ -99,6 +103,8 @@ export function WorktreeActionsDropdown({
onOpenInEditor,
onOpenInIntegratedTerminal,
onOpenInExternalTerminal,
onViewChanges,
onDiscardChanges,
onCommit,
onCreatePR,
onAddressPRComments,
@@ -434,6 +440,13 @@ export function WorktreeActionsDropdown({
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{worktree.hasChanges && (
<DropdownMenuItem onClick={() => onViewChanges(worktree)} className="text-xs">
<Eye className="w-3.5 h-3.5 mr-2" />
View Changes
</DropdownMenuItem>
)}
{worktree.hasChanges && (
<TooltipWrapper
showTooltip={!gitRepoStatus.isGitRepo}
@@ -509,9 +522,30 @@ export function WorktreeActionsDropdown({
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
{worktree.hasChanges && (
<TooltipWrapper
showTooltip={!gitRepoStatus.isGitRepo}
tooltipContent="Not a git repository"
>
<DropdownMenuItem
onClick={() => gitRepoStatus.isGitRepo && onDiscardChanges(worktree)}
disabled={!gitRepoStatus.isGitRepo}
className={cn(
'text-xs text-destructive focus:text-destructive',
!gitRepoStatus.isGitRepo && 'opacity-50 cursor-not-allowed'
)}
>
<Undo2 className="w-3.5 h-3.5 mr-2" />
Discard Changes
{!gitRepoStatus.isGitRepo && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
</TooltipWrapper>
)}
{!worktree.isMain && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDeleteWorktree(worktree)}
className="text-xs text-destructive focus:text-destructive"

View File

@@ -42,6 +42,8 @@ interface WorktreeTabProps {
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
onViewChanges: (worktree: WorktreeInfo) => void;
onDiscardChanges: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
@@ -90,6 +92,8 @@ export function WorktreeTab({
onOpenInEditor,
onOpenInIntegratedTerminal,
onOpenInExternalTerminal,
onViewChanges,
onDiscardChanges,
onCommit,
onCreatePR,
onAddressPRComments,
@@ -375,6 +379,8 @@ export function WorktreeTab({
onOpenInEditor={onOpenInEditor}
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
onOpenInExternalTerminal={onOpenInExternalTerminal}
onViewChanges={onViewChanges}
onDiscardChanges={onDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}

View File

@@ -22,6 +22,9 @@ import {
BranchSwitchDropdown,
} from './components';
import { useAppStore } from '@/store/app-store';
import { ViewWorktreeChangesDialog } from '../dialogs';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { Undo2 } from 'lucide-react';
export function WorktreePanel({
projectPath,
@@ -156,6 +159,14 @@ export function WorktreePanel({
// Track whether init script exists for the project
const [hasInitScript, setHasInitScript] = useState(false);
// View changes dialog state
const [viewChangesDialogOpen, setViewChangesDialogOpen] = useState(false);
const [viewChangesWorktree, setViewChangesWorktree] = useState<WorktreeInfo | null>(null);
// Discard changes confirmation dialog state
const [discardChangesDialogOpen, setDiscardChangesDialogOpen] = useState(false);
const [discardChangesWorktree, setDiscardChangesWorktree] = useState<WorktreeInfo | null>(null);
// Log panel state management
const [logPanelOpen, setLogPanelOpen] = useState(false);
const [logPanelWorktree, setLogPanelWorktree] = useState<WorktreeInfo | null>(null);
@@ -242,6 +253,41 @@ export function WorktreePanel({
[projectPath]
);
const handleViewChanges = useCallback((worktree: WorktreeInfo) => {
setViewChangesWorktree(worktree);
setViewChangesDialogOpen(true);
}, []);
const handleDiscardChanges = useCallback((worktree: WorktreeInfo) => {
setDiscardChangesWorktree(worktree);
setDiscardChangesDialogOpen(true);
}, []);
const handleConfirmDiscardChanges = useCallback(async () => {
if (!discardChangesWorktree) return;
try {
const api = getHttpApiClient();
const result = await api.worktree.discardChanges(discardChangesWorktree.path);
if (result.success) {
toast.success('Changes discarded', {
description: `Discarded changes in ${discardChangesWorktree.branch}`,
});
// Refresh worktrees to update the changes status
fetchWorktrees({ silent: true });
} else {
toast.error('Failed to discard changes', {
description: result.error || 'Unknown error',
});
}
} catch (error) {
toast.error('Failed to discard changes', {
description: error instanceof Error ? error.message : 'Unknown error',
});
}
}, [discardChangesWorktree, fetchWorktrees]);
// Handle opening the log panel for a specific worktree
const handleViewDevServerLogs = useCallback((worktree: WorktreeInfo) => {
setLogPanelWorktree(worktree);
@@ -312,6 +358,8 @@ export function WorktreePanel({
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onViewChanges={handleViewChanges}
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
@@ -357,6 +405,36 @@ export function WorktreePanel({
</Button>
</>
)}
{/* View Changes Dialog */}
<ViewWorktreeChangesDialog
open={viewChangesDialogOpen}
onOpenChange={setViewChangesDialogOpen}
worktree={viewChangesWorktree}
projectPath={projectPath}
/>
{/* Discard Changes Confirmation Dialog */}
<ConfirmDialog
open={discardChangesDialogOpen}
onOpenChange={setDiscardChangesDialogOpen}
onConfirm={handleConfirmDiscardChanges}
title="Discard Changes"
description={`Are you sure you want to discard all changes in "${discardChangesWorktree?.branch}"? This will reset staged changes, discard modifications to tracked files, and remove untracked files. This action cannot be undone.`}
icon={Undo2}
iconClassName="text-destructive"
confirmText="Discard Changes"
confirmVariant="destructive"
/>
{/* Dev Server Logs Panel */}
<DevServerLogsPanel
open={logPanelOpen}
onClose={handleCloseLogPanel}
worktree={logPanelWorktree}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
/>
</div>
);
}
@@ -403,6 +481,8 @@ export function WorktreePanel({
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onViewChanges={handleViewChanges}
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
@@ -465,6 +545,8 @@ export function WorktreePanel({
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onViewChanges={handleViewChanges}
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
@@ -511,6 +593,27 @@ export function WorktreePanel({
</>
)}
{/* View Changes Dialog */}
<ViewWorktreeChangesDialog
open={viewChangesDialogOpen}
onOpenChange={setViewChangesDialogOpen}
worktree={viewChangesWorktree}
projectPath={projectPath}
/>
{/* Discard Changes Confirmation Dialog */}
<ConfirmDialog
open={discardChangesDialogOpen}
onOpenChange={setDiscardChangesDialogOpen}
onConfirm={handleConfirmDiscardChanges}
title="Discard Changes"
description={`Are you sure you want to discard all changes in "${discardChangesWorktree?.branch}"? This will reset staged changes, discard modifications to tracked files, and remove untracked files. This action cannot be undone.`}
icon={Undo2}
iconClassName="text-destructive"
confirmText="Discard Changes"
confirmVariant="destructive"
/>
{/* Dev Server Logs Panel */}
<DevServerLogsPanel
open={logPanelOpen}

View File

@@ -2024,6 +2024,20 @@ function createMockWorktreeAPI(): WorktreeAPI {
console.log('[Mock] Unsubscribing from init script events');
};
},
discardChanges: async (worktreePath: string) => {
console.log('[Mock] Discarding changes:', { worktreePath });
return {
success: true,
result: {
discarded: true,
filesDiscarded: 0,
filesRemaining: 0,
branch: 'main',
message: 'Mock: Changes discarded successfully',
},
};
},
};
}

View File

@@ -1851,6 +1851,8 @@ export class HttpApiClient implements ElectronAPI {
this.httpDelete('/api/worktree/init-script', { projectPath }),
runInitScript: (projectPath: string, worktreePath: string, branch: string) =>
this.post('/api/worktree/run-init-script', { projectPath, worktreePath, branch }),
discardChanges: (worktreePath: string) =>
this.post('/api/worktree/discard-changes', { worktreePath }),
onInitScriptEvent: (
callback: (event: {
type: 'worktree:init-started' | 'worktree:init-output' | 'worktree:init-completed';

View File

@@ -1218,6 +1218,19 @@ export interface WorktreeAPI {
payload: unknown;
}) => void
) => () => void;
// Discard changes for a worktree
discardChanges: (worktreePath: string) => Promise<{
success: boolean;
result?: {
discarded: boolean;
filesDiscarded: number;
filesRemaining: number;
branch: string;
message: string;
};
error?: string;
}>;
}
export interface GitAPI {