diff --git a/apps/backend/internal/agent/handlers/git_handlers.go b/apps/backend/internal/agent/handlers/git_handlers.go index bf34cfbd82..75e412401c 100644 --- a/apps/backend/internal/agent/handlers/git_handlers.go +++ b/apps/backend/internal/agent/handlers/git_handlers.go @@ -37,6 +37,7 @@ func (h *GitHandlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionWorktreeCommit, h.wsCommit) d.RegisterFunc(ws.ActionWorktreeStage, h.wsStage) d.RegisterFunc(ws.ActionWorktreeUnstage, h.wsUnstage) + d.RegisterFunc(ws.ActionWorktreeDiscard, h.wsDiscard) d.RegisterFunc(ws.ActionWorktreeCreatePR, h.wsCreatePR) d.RegisterFunc(ws.ActionSessionCommitDiff, h.wsCommitDiff) } @@ -91,6 +92,12 @@ type GitUnstageRequest struct { Paths []string `json:"paths"` // Empty = unstage all } +// GitDiscardRequest for worktree.discard action +type GitDiscardRequest struct { + SessionID string `json:"session_id"` + Paths []string `json:"paths"` // Required - files to discard +} + // GitCreatePRRequest for worktree.create_pr action type GitCreatePRRequest struct { SessionID string `json:"session_id"` @@ -309,6 +316,34 @@ func (h *GitHandlers) wsUnstage(ctx context.Context, msg *ws.Message) (*ws.Messa return ws.NewResponse(msg.ID, msg.Action, result) } +// wsDiscard handles worktree.discard action +func (h *GitHandlers) wsDiscard(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req GitDiscardRequest + if err := msg.ParsePayload(&req); err != nil { + return nil, fmt.Errorf("invalid payload: %w", err) + } + + if req.SessionID == "" { + return nil, fmt.Errorf("session_id is required") + } + + if len(req.Paths) == 0 { + return nil, fmt.Errorf("paths are required") + } + + client, err := h.getAgentCtlClient(req.SessionID) + if err != nil { + return nil, err + } + + result, err := client.GitDiscard(ctx, req.Paths) + if err != nil { + return nil, fmt.Errorf("discard failed: %w", err) + } + + return ws.NewResponse(msg.ID, msg.Action, result) +} + // wsCreatePR handles worktree.create_pr action func (h *GitHandlers) wsCreatePR(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req GitCreatePRRequest diff --git a/apps/backend/internal/agentctl/client/git.go b/apps/backend/internal/agentctl/client/git.go index 26ece30f05..4f6c65fe9c 100644 --- a/apps/backend/internal/agentctl/client/git.go +++ b/apps/backend/internal/agentctl/client/git.go @@ -120,6 +120,17 @@ func (c *Client) GitUnstage(ctx context.Context, paths []string) (*GitOperationR return c.gitOperation(ctx, "/api/v1/git/unstage", payload) } +// GitDiscard discards changes to files, reverting them to HEAD. +// Paths must not be empty - at least one file must be specified. +func (c *Client) GitDiscard(ctx context.Context, paths []string) (*GitOperationResult, error) { + payload := struct { + Paths []string `json:"paths"` + }{ + Paths: paths, + } + return c.gitOperation(ctx, "/api/v1/git/discard", payload) +} + // GitCreatePR creates a pull request using the gh CLI. func (c *Client) GitCreatePR(ctx context.Context, title, body, baseBranch string) (*PRCreateResult, error) { payload := struct { diff --git a/apps/backend/internal/agentctl/server/api/git.go b/apps/backend/internal/agentctl/server/api/git.go index 8aaea681aa..80ed2103ae 100644 --- a/apps/backend/internal/agentctl/server/api/git.go +++ b/apps/backend/internal/agentctl/server/api/git.go @@ -51,6 +51,11 @@ type GitUnstageRequest struct { Paths []string `json:"paths"` // Empty = unstage all } +// GitDiscardRequest for POST /api/v1/git/discard +type GitDiscardRequest struct { + Paths []string `json:"paths"` // Required - files to discard +} + // GitShowCommitRequest for GET /api/v1/git/commit/:sha type GitShowCommitRequest struct { CommitSHA string `uri:"sha" binding:"required"` @@ -275,6 +280,37 @@ func (s *Server) handleGitUnstage(c *gin.Context) { c.JSON(http.StatusOK, result) } +// handleGitDiscard handles POST /api/v1/git/discard +func (s *Server) handleGitDiscard(c *gin.Context) { + var req GitDiscardRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, process.GitOperationResult{ + Success: false, + Operation: "discard", + Error: "invalid request: " + err.Error(), + }) + return + } + + if len(req.Paths) == 0 { + c.JSON(http.StatusBadRequest, process.GitOperationResult{ + Success: false, + Operation: "discard", + Error: "paths are required", + }) + return + } + + gitOp := s.procMgr.GitOperator() + result, err := gitOp.Discard(c.Request.Context(), req.Paths) + if err != nil { + s.handleGitError(c, "discard", err) + return + } + + c.JSON(http.StatusOK, result) +} + // handleGitCreatePR handles POST /api/v1/git/create-pr func (s *Server) handleGitCreatePR(c *gin.Context) { var req GitCreatePRRequest diff --git a/apps/backend/internal/agentctl/server/api/server.go b/apps/backend/internal/agentctl/server/api/server.go index dbdd98c977..45acf47f6b 100644 --- a/apps/backend/internal/agentctl/server/api/server.go +++ b/apps/backend/internal/agentctl/server/api/server.go @@ -111,6 +111,7 @@ func (s *Server) setupRoutes() { api.POST("/git/commit", s.handleGitCommit) api.POST("/git/stage", s.handleGitStage) api.POST("/git/unstage", s.handleGitUnstage) + api.POST("/git/discard", s.handleGitDiscard) api.POST("/git/create-pr", s.handleGitCreatePR) api.GET("/git/commit/:sha", s.handleGitShowCommit) } diff --git a/apps/backend/internal/agentctl/server/process/git.go b/apps/backend/internal/agentctl/server/process/git.go index 82c9b2b84f..5844400edc 100644 --- a/apps/backend/internal/agentctl/server/process/git.go +++ b/apps/backend/internal/agentctl/server/process/git.go @@ -504,6 +504,46 @@ func (g *GitOperator) Unstage(ctx context.Context, paths []string) (*GitOperatio return result, nil } +// Discard discards changes to files, reverting them to HEAD state. +// This removes both staged and unstaged changes. +// If paths is empty, returns an error (discarding all files requires explicit confirmation). +func (g *GitOperator) Discard(ctx context.Context, paths []string) (*GitOperationResult, error) { + if !g.tryLock("discard") { + return nil, ErrOperationInProgress + } + defer g.unlock() + + result := &GitOperationResult{ + Operation: "discard", + } + + // Require explicit paths for safety + if len(paths) == 0 { + result.Error = "no files specified to discard" + return result, nil + } + + // Use git restore to discard changes (Git 2.23+) + // --source=HEAD: restore from HEAD + // --staged: remove from index + // --worktree: remove from working tree + // --: separator between options and paths + args := append([]string{"restore", "--source=HEAD", "--staged", "--worktree", "--"}, paths...) + + output, err := g.runGitCommand(ctx, args...) + result.Output = output + + if err != nil { + result.Error = err.Error() + return result, nil + } + + result.Success = true + g.triggerFsNotify() + g.logger.Info("discard completed", zap.Int("files", len(paths))) + return result, nil +} + // Abort aborts an in-progress merge or rebase operation. func (g *GitOperator) Abort(ctx context.Context, operation string) (*GitOperationResult, error) { if !g.tryLock("abort") { diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index be90e909f4..dbde9dff05 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -200,6 +200,7 @@ const ( ActionWorktreeCommit = "worktree.commit" // Commit changes ActionWorktreeStage = "worktree.stage" // Stage files for commit ActionWorktreeUnstage = "worktree.unstage" // Unstage files from index + ActionWorktreeDiscard = "worktree.discard" // Discard changes to files ActionWorktreeCreatePR = "worktree.create_pr" // Create a pull request // User actions diff --git a/apps/web/components/task/task-changes-panel.tsx b/apps/web/components/task/task-changes-panel.tsx index 18658062bf..0b09a00222 100644 --- a/apps/web/components/task/task-changes-panel.tsx +++ b/apps/web/components/task/task-changes-panel.tsx @@ -13,12 +13,24 @@ import { Badge } from '@kandev/ui/badge'; import { Button } from '@kandev/ui/button'; import { SessionPanelContent } from '@kandev/ui/pannel-session'; import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@kandev/ui/alert-dialog'; import { cn } from '@/lib/utils'; +import { useToast } from '@/components/toast-provider'; import { getLanguageFromPath } from '@/lib/languages'; import { getLocalStorage, setLocalStorage } from '@/lib/local-storage'; import { useAppStore } from '@/components/state-provider'; import { useSessionGitStatus } from '@/hooks/domains/session/use-session-git-status'; import { useCumulativeDiff } from '@/hooks/domains/session/use-cumulative-diff'; +import { useGitOperations } from '@/hooks/use-git-operations'; import type { FileInfo } from '@/lib/state/slices'; import type { SelectedDiff } from './task-layout'; @@ -73,10 +85,13 @@ const TaskChangesPanel = memo(function TaskChangesPanel({ const [diffViewMode, setDiffViewMode] = useState<'unified' | 'split'>(() => getLocalStorage('task-diff-view-mode', DEFAULT_DIFF_MODE) ); + const [showDiscardDialog, setShowDiscardDialog] = useState(false); const { resolvedTheme } = useTheme(); + const { toast } = useToast(); const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); const gitStatus = useSessionGitStatus(activeSessionId); + const { discard, isLoading: isDiscarding } = useGitOperations(activeSessionId); // Fetch cumulative diff (all commits combined) when no specific file is selected const { diff: cumulativeDiff, loading: cumulativeLoading } = useCumulativeDiff( selectedDiff ? null : activeSessionId @@ -115,11 +130,54 @@ const TaskChangesPanel = memo(function TaskChangesPanel({ // Use provided content if available, otherwise fall back to git status const selectedDiffContent = selectedDiff?.content ?? selectedFile?.diff ?? ''; const isSingleDiffSelected = Boolean(selectedDiffPath && (hasProvidedContent || selectedFile)); + // Discard only works for uncommitted changes (not committed/historical diffs) + const canDiscard = Boolean(selectedDiffPath && selectedFile && !hasProvidedContent); const hasUncommitted = uncommittedFiles.length > 0; const hasCommitted = committedFiles.length > 0; const hasAnyChanges = hasUncommitted || hasCommitted; + const handleDiscardClick = () => { + console.log('Discard button clicked', { selectedDiffPath, canDiscard }); + setShowDiscardDialog(true); + }; + + const handleDiscardConfirm = async () => { + if (!selectedDiffPath) { + console.error('No selectedDiffPath'); + return; + } + + console.log('Discarding changes for:', selectedDiffPath); + try { + const result = await discard([selectedDiffPath]); + console.log('Discard result:', result); + if (result.success) { + toast({ + title: 'Changes discarded', + description: `Successfully discarded changes to ${selectedDiffPath}`, + variant: 'success', + }); + onClearSelected(); + } else { + toast({ + title: 'Failed to discard changes', + description: result.error || 'An unknown error occurred', + variant: 'error', + }); + } + } catch (error) { + console.error('Discard error:', error); + toast({ + title: 'Failed to discard changes', + description: error instanceof Error ? error.message : 'An unknown error occurred', + variant: 'error', + }); + } finally { + setShowDiscardDialog(false); + } + }; + return (
{/* Header toolbar */} @@ -174,13 +232,16 @@ const TaskChangesPanel = memo(function TaskChangesPanel({ variant="ghost" size="sm" className="h-7 px-2 text-xs cursor-pointer" - disabled={!isSingleDiffSelected} + disabled={!canDiscard || isDiscarding} + onClick={handleDiscardClick} > - Discard changes + + {canDiscard ? 'Discard changes' : hasProvidedContent ? 'Cannot discard committed changes' : 'Select an uncommitted file'} +
@@ -335,6 +396,25 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
)} + + {/* Discard confirmation dialog */} + + + + Discard changes? + + This will permanently discard all changes to{' '} + {selectedDiffPath}. This action cannot be undone. + + + + Cancel + + Discard + + + +
); }); diff --git a/apps/web/components/task/task-files-panel.tsx b/apps/web/components/task/task-files-panel.tsx index e49dbab806..41eb926c16 100644 --- a/apps/web/components/task/task-files-panel.tsx +++ b/apps/web/components/task/task-files-panel.tsx @@ -16,6 +16,16 @@ import { import { TabsContent } from '@kandev/ui/tabs'; import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; import { SessionPanel, SessionPanelContent } from '@kandev/ui/pannel-session'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@kandev/ui/alert-dialog'; import { LineStat } from '@/components/diff-stat'; import { cn } from '@/lib/utils'; import { useAppStore } from '@/components/state-provider'; @@ -89,6 +99,8 @@ const splitPath = (path: string) => { const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile }: TaskFilesPanelProps) { const [topTab, setTopTab] = useState<'diff' | 'files'>('diff'); + const [showDiscardDialog, setShowDiscardDialog] = useState(false); + const [fileToDiscard, setFileToDiscard] = useState(null); const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); const gitStatus = useSessionGitStatus(activeSessionId); const { commits } = useSessionCommits(activeSessionId ?? null); @@ -138,6 +150,42 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile } } }, [expandedCommit, fetchCommitDiff]); + // Handle discard changes + const handleDiscardClick = useCallback((filePath: string) => { + setFileToDiscard(filePath); + setShowDiscardDialog(true); + }, []); + + const handleDiscardConfirm = useCallback(async () => { + if (!fileToDiscard) return; + + try { + const result = await gitOps.discard([fileToDiscard]); + if (result.success) { + toast({ + title: 'Changes discarded', + description: `Successfully discarded changes to ${fileToDiscard}`, + variant: 'success', + }); + } else { + toast({ + title: 'Failed to discard changes', + description: result.error || 'An unknown error occurred', + variant: 'error', + }); + } + } catch (error) { + toast({ + title: 'Failed to discard changes', + description: error instanceof Error ? error.message : 'An unknown error occurred', + variant: 'error', + }); + } finally { + setShowDiscardDialog(false); + setFileToDiscard(null); + } + }, [fileToDiscard, gitOps, toast]); + // Convert git status files to array for display const changedFiles = useMemo(() => { if (!gitStatus?.files || Object.keys(gitStatus.files).length === 0) { @@ -238,9 +286,10 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile }