diff --git a/apps/backend/cmd/kandev/gateway.go b/apps/backend/cmd/kandev/gateway.go index 785a932bc6..a39e20526c 100644 --- a/apps/backend/cmd/kandev/gateway.go +++ b/apps/backend/cmd/kandev/gateway.go @@ -56,6 +56,9 @@ func provideGateway( shellHandlers := agenthandlers.NewShellHandlers(lifecycleMgr, log) shellHandlers.RegisterHandlers(gateway.Dispatcher) + + gitHandlers := agenthandlers.NewGitHandlers(lifecycleMgr, log) + gitHandlers.RegisterHandlers(gateway.Dispatcher) } go gateway.Hub.Run(ctx) diff --git a/apps/backend/internal/agent/handlers/git_handlers.go b/apps/backend/internal/agent/handlers/git_handlers.go new file mode 100644 index 0000000000..2c6b7bb676 --- /dev/null +++ b/apps/backend/internal/agent/handlers/git_handlers.go @@ -0,0 +1,315 @@ +// Package handlers provides WebSocket and HTTP handlers for agent operations. +package handlers + +import ( + "context" + "fmt" + + "github.com/kandev/kandev/internal/agent/lifecycle" + "github.com/kandev/kandev/internal/agentctl/client" + "github.com/kandev/kandev/internal/common/logger" + ws "github.com/kandev/kandev/pkg/websocket" + "go.uber.org/zap" +) + +// GitHandlers provides WebSocket handlers for git worktree operations. +// Operations are executed via agentctl which runs in the worktree context. +type GitHandlers struct { + lifecycleMgr *lifecycle.Manager + logger *logger.Logger +} + +// NewGitHandlers creates a new GitHandlers instance +func NewGitHandlers(lifecycleMgr *lifecycle.Manager, log *logger.Logger) *GitHandlers { + return &GitHandlers{ + lifecycleMgr: lifecycleMgr, + logger: log.WithFields(zap.String("component", "git_handlers")), + } +} + +// RegisterHandlers registers git handlers with the WebSocket dispatcher +func (h *GitHandlers) RegisterHandlers(d *ws.Dispatcher) { + d.RegisterFunc(ws.ActionWorktreePull, h.wsPull) + d.RegisterFunc(ws.ActionWorktreePush, h.wsPush) + d.RegisterFunc(ws.ActionWorktreeRebase, h.wsRebase) + d.RegisterFunc(ws.ActionWorktreeMerge, h.wsMerge) + d.RegisterFunc(ws.ActionWorktreeAbort, h.wsAbort) + d.RegisterFunc(ws.ActionWorktreeCommit, h.wsCommit) + d.RegisterFunc(ws.ActionWorktreeStage, h.wsStage) + d.RegisterFunc(ws.ActionWorktreeCreatePR, h.wsCreatePR) +} + +// GitPullRequest for worktree.pull action +type GitPullRequest struct { + SessionID string `json:"session_id"` + Rebase bool `json:"rebase"` +} + +// GitPushRequest for worktree.push action +type GitPushRequest struct { + SessionID string `json:"session_id"` + Force bool `json:"force"` + SetUpstream bool `json:"set_upstream"` +} + +// GitRebaseRequest for worktree.rebase action +type GitRebaseRequest struct { + SessionID string `json:"session_id"` + BaseBranch string `json:"base_branch"` +} + +// GitMergeRequest for worktree.merge action +type GitMergeRequest struct { + SessionID string `json:"session_id"` + BaseBranch string `json:"base_branch"` +} + +// GitAbortRequest for worktree.abort action +type GitAbortRequest struct { + SessionID string `json:"session_id"` + Operation string `json:"operation"` // "merge" or "rebase" +} + +// GitCommitRequest for worktree.commit action +type GitCommitRequest struct { + SessionID string `json:"session_id"` + Message string `json:"message"` + StageAll bool `json:"stage_all"` +} + +// GitStageRequest for worktree.stage action +type GitStageRequest struct { + SessionID string `json:"session_id"` + Paths []string `json:"paths"` // Empty = stage all +} + +// GitCreatePRRequest for worktree.create_pr action +type GitCreatePRRequest struct { + SessionID string `json:"session_id"` + Title string `json:"title"` + Body string `json:"body"` + BaseBranch string `json:"base_branch"` +} + +// wsPull handles worktree.pull action +func (h *GitHandlers) wsPull(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req GitPullRequest + 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") + } + + client, err := h.getAgentCtlClient(req.SessionID) + if err != nil { + return nil, err + } + + result, err := client.GitPull(ctx, req.Rebase) + if err != nil { + return nil, fmt.Errorf("pull failed: %w", err) + } + + return ws.NewResponse(msg.ID, msg.Action, result) +} + +// wsPush handles worktree.push action +func (h *GitHandlers) wsPush(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req GitPushRequest + 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") + } + + client, err := h.getAgentCtlClient(req.SessionID) + if err != nil { + return nil, err + } + + result, err := client.GitPush(ctx, req.Force, req.SetUpstream) + if err != nil { + return nil, fmt.Errorf("push failed: %w", err) + } + + return ws.NewResponse(msg.ID, msg.Action, result) +} + +// wsRebase handles worktree.rebase action +func (h *GitHandlers) wsRebase(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req GitRebaseRequest + 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 req.BaseBranch == "" { + return nil, fmt.Errorf("base_branch is required") + } + + client, err := h.getAgentCtlClient(req.SessionID) + if err != nil { + return nil, err + } + + result, err := client.GitRebase(ctx, req.BaseBranch) + if err != nil { + return nil, fmt.Errorf("rebase failed: %w", err) + } + + return ws.NewResponse(msg.ID, msg.Action, result) +} + +// wsMerge handles worktree.merge action +func (h *GitHandlers) wsMerge(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req GitMergeRequest + 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 req.BaseBranch == "" { + return nil, fmt.Errorf("base_branch is required") + } + + client, err := h.getAgentCtlClient(req.SessionID) + if err != nil { + return nil, err + } + + result, err := client.GitMerge(ctx, req.BaseBranch) + if err != nil { + return nil, fmt.Errorf("merge failed: %w", err) + } + + return ws.NewResponse(msg.ID, msg.Action, result) +} + +// wsAbort handles worktree.abort action +func (h *GitHandlers) wsAbort(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req GitAbortRequest + 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 req.Operation != "merge" && req.Operation != "rebase" { + return nil, fmt.Errorf("operation must be 'merge' or 'rebase'") + } + + client, err := h.getAgentCtlClient(req.SessionID) + if err != nil { + return nil, err + } + + result, err := client.GitAbort(ctx, req.Operation) + if err != nil { + return nil, fmt.Errorf("abort failed: %w", err) + } + + return ws.NewResponse(msg.ID, msg.Action, result) +} + +// wsCommit handles worktree.commit action +func (h *GitHandlers) wsCommit(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req GitCommitRequest + 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 req.Message == "" { + return nil, fmt.Errorf("message is required") + } + + client, err := h.getAgentCtlClient(req.SessionID) + if err != nil { + return nil, err + } + + result, err := client.GitCommit(ctx, req.Message, req.StageAll) + if err != nil { + return nil, fmt.Errorf("commit failed: %w", err) + } + + return ws.NewResponse(msg.ID, msg.Action, result) +} + +// wsStage handles worktree.stage action +func (h *GitHandlers) wsStage(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req GitStageRequest + 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") + } + + client, err := h.getAgentCtlClient(req.SessionID) + if err != nil { + return nil, err + } + + result, err := client.GitStage(ctx, req.Paths) + if err != nil { + return nil, fmt.Errorf("stage 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 + 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 req.Title == "" { + return nil, fmt.Errorf("title is required") + } + + client, err := h.getAgentCtlClient(req.SessionID) + if err != nil { + return nil, err + } + + result, err := client.GitCreatePR(ctx, req.Title, req.Body, req.BaseBranch) + if err != nil { + return nil, fmt.Errorf("create PR failed: %w", err) + } + + return ws.NewResponse(msg.ID, msg.Action, result) +} + +// getAgentCtlClient gets the agentctl client for a session +func (h *GitHandlers) getAgentCtlClient(sessionID string) (*client.Client, error) { + execution, ok := h.lifecycleMgr.GetExecutionBySessionID(sessionID) + if !ok { + return nil, fmt.Errorf("no agent running for session %s", sessionID) + } + + c := execution.GetAgentCtlClient() + if c == nil { + return nil, fmt.Errorf("agent client not available for session %s", sessionID) + } + + return c, nil +} + diff --git a/apps/backend/internal/agentctl/client/git.go b/apps/backend/internal/agentctl/client/git.go new file mode 100644 index 0000000000..fd95703c05 --- /dev/null +++ b/apps/backend/internal/agentctl/client/git.go @@ -0,0 +1,199 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" +) + +// GitOperationResult represents the result of a git operation. +// This matches the server-side process.GitOperationResult. +type GitOperationResult struct { + Success bool `json:"success"` + Operation string `json:"operation"` + Output string `json:"output"` + Error string `json:"error,omitempty"` + ConflictFiles []string `json:"conflict_files,omitempty"` +} + +// PRCreateResult represents the result of a PR creation operation. +// This matches the server-side process.PRCreateResult. +type PRCreateResult struct { + Success bool `json:"success"` + PRURL string `json:"pr_url,omitempty"` + Output string `json:"output,omitempty"` + Error string `json:"error,omitempty"` +} + +// GitPull performs a git pull operation on the worktree. +// If rebase is true, uses git pull --rebase. +func (c *Client) GitPull(ctx context.Context, rebase bool) (*GitOperationResult, error) { + payload := struct { + Rebase bool `json:"rebase"` + }{ + Rebase: rebase, + } + return c.gitOperation(ctx, "/api/v1/git/pull", payload) +} + +// GitPush performs a git push operation on the worktree. +// If force is true, uses --force-with-lease. +// If setUpstream is true, uses --set-upstream. +func (c *Client) GitPush(ctx context.Context, force, setUpstream bool) (*GitOperationResult, error) { + payload := struct { + Force bool `json:"force"` + SetUpstream bool `json:"set_upstream"` + }{ + Force: force, + SetUpstream: setUpstream, + } + return c.gitOperation(ctx, "/api/v1/git/push", payload) +} + +// GitRebase rebases the worktree branch onto the specified base branch. +// It first fetches origin/, then rebases onto it. +func (c *Client) GitRebase(ctx context.Context, baseBranch string) (*GitOperationResult, error) { + payload := struct { + BaseBranch string `json:"base_branch"` + }{ + BaseBranch: baseBranch, + } + return c.gitOperation(ctx, "/api/v1/git/rebase", payload) +} + +// GitMerge merges the specified base branch into the worktree branch. +// It first fetches origin/, then merges it. +func (c *Client) GitMerge(ctx context.Context, baseBranch string) (*GitOperationResult, error) { + payload := struct { + BaseBranch string `json:"base_branch"` + }{ + BaseBranch: baseBranch, + } + return c.gitOperation(ctx, "/api/v1/git/merge", payload) +} + +// GitAbort aborts an in-progress merge or rebase operation. +// The operation parameter must be "merge" or "rebase". +func (c *Client) GitAbort(ctx context.Context, operation string) (*GitOperationResult, error) { + payload := struct { + Operation string `json:"operation"` + }{ + Operation: operation, + } + return c.gitOperation(ctx, "/api/v1/git/abort", payload) +} + +// GitCommit creates a commit with the specified message. +// If stageAll is true, all changes are staged before committing. +func (c *Client) GitCommit(ctx context.Context, message string, stageAll bool) (*GitOperationResult, error) { + payload := struct { + Message string `json:"message"` + StageAll bool `json:"stage_all"` + }{ + Message: message, + StageAll: stageAll, + } + return c.gitOperation(ctx, "/api/v1/git/commit", payload) +} + +// GitStage stages files for commit. +// If paths is empty, stages all changes (git add -A). +func (c *Client) GitStage(ctx context.Context, paths []string) (*GitOperationResult, error) { + payload := struct { + Paths []string `json:"paths"` + }{ + Paths: paths, + } + return c.gitOperation(ctx, "/api/v1/git/stage", 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 { + Title string `json:"title"` + Body string `json:"body"` + BaseBranch string `json:"base_branch"` + }{ + Title: title, + Body: body, + BaseBranch: baseBranch, + } + + reqBody, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/git/create-pr", bytes.NewReader(reqBody)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := readResponseBody(resp) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var result PRCreateResult + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to parse response (status %d, body: %s): %w", + resp.StatusCode, truncateBody(respBody), err) + } + + // For 409 Conflict (operation in progress), return the result with error set + // For other HTTP errors, return as error + if resp.StatusCode >= 400 && resp.StatusCode != http.StatusConflict { + return &result, fmt.Errorf("create PR failed with status %d: %s", resp.StatusCode, result.Error) + } + + return &result, nil +} + +// gitOperation is a helper that performs a git operation via HTTP POST. +func (c *Client) gitOperation(ctx context.Context, path string, payload interface{}) (*GitOperationResult, error) { + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+path, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := readResponseBody(resp) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var result GitOperationResult + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to parse response (status %d, body: %s): %w", + resp.StatusCode, truncateBody(respBody), err) + } + + // For 409 Conflict (operation in progress), return the result with error set + // For other HTTP errors, return as error + if resp.StatusCode >= 400 && resp.StatusCode != http.StatusConflict { + return &result, fmt.Errorf("git operation failed with status %d: %s", resp.StatusCode, result.Error) + } + + return &result, nil +} + diff --git a/apps/backend/internal/agentctl/server/api/git.go b/apps/backend/internal/agentctl/server/api/git.go new file mode 100644 index 0000000000..fe22133024 --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/git.go @@ -0,0 +1,304 @@ +package api + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/kandev/kandev/internal/agentctl/server/process" + "go.uber.org/zap" +) + +// GitPullRequest for POST /api/v1/git/pull +type GitPullRequest struct { + Rebase bool `json:"rebase"` +} + +// GitPushRequest for POST /api/v1/git/push +type GitPushRequest struct { + Force bool `json:"force"` + SetUpstream bool `json:"set_upstream"` +} + +// GitRebaseRequest for POST /api/v1/git/rebase +type GitRebaseRequest struct { + BaseBranch string `json:"base_branch"` +} + +// GitMergeRequest for POST /api/v1/git/merge +type GitMergeRequest struct { + BaseBranch string `json:"base_branch"` +} + +// GitAbortRequest for POST /api/v1/git/abort +type GitAbortRequest struct { + Operation string `json:"operation"` // "merge" or "rebase" +} + +// GitCommitRequest for POST /api/v1/git/commit +type GitCommitRequest struct { + Message string `json:"message"` + StageAll bool `json:"stage_all"` +} + +// GitStageRequest for POST /api/v1/git/stage +type GitStageRequest struct { + Paths []string `json:"paths"` // Empty = stage all +} + +// GitCreatePRRequest for POST /api/v1/git/create-pr +type GitCreatePRRequest struct { + Title string `json:"title"` + Body string `json:"body"` + BaseBranch string `json:"base_branch"` +} + +// handleGitPull handles POST /api/v1/git/pull +func (s *Server) handleGitPull(c *gin.Context) { + var req GitPullRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, process.GitOperationResult{ + Success: false, + Operation: "pull", + Error: "invalid request: " + err.Error(), + }) + return + } + + gitOp := s.procMgr.GitOperator() + result, err := gitOp.Pull(c.Request.Context(), req.Rebase) + if err != nil { + s.handleGitError(c, "pull", err) + return + } + + c.JSON(http.StatusOK, result) +} + +// handleGitPush handles POST /api/v1/git/push +func (s *Server) handleGitPush(c *gin.Context) { + var req GitPushRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, process.GitOperationResult{ + Success: false, + Operation: "push", + Error: "invalid request: " + err.Error(), + }) + return + } + + gitOp := s.procMgr.GitOperator() + result, err := gitOp.Push(c.Request.Context(), req.Force, req.SetUpstream) + if err != nil { + s.handleGitError(c, "push", err) + return + } + + c.JSON(http.StatusOK, result) +} + +// handleGitRebase handles POST /api/v1/git/rebase +func (s *Server) handleGitRebase(c *gin.Context) { + var req GitRebaseRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, process.GitOperationResult{ + Success: false, + Operation: "rebase", + Error: "invalid request: " + err.Error(), + }) + return + } + + if req.BaseBranch == "" { + c.JSON(http.StatusBadRequest, process.GitOperationResult{ + Success: false, + Operation: "rebase", + Error: "base_branch is required", + }) + return + } + + gitOp := s.procMgr.GitOperator() + result, err := gitOp.Rebase(c.Request.Context(), req.BaseBranch) + if err != nil { + s.handleGitError(c, "rebase", err) + return + } + + c.JSON(http.StatusOK, result) +} + +// handleGitMerge handles POST /api/v1/git/merge +func (s *Server) handleGitMerge(c *gin.Context) { + var req GitMergeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, process.GitOperationResult{ + Success: false, + Operation: "merge", + Error: "invalid request: " + err.Error(), + }) + return + } + + if req.BaseBranch == "" { + c.JSON(http.StatusBadRequest, process.GitOperationResult{ + Success: false, + Operation: "merge", + Error: "base_branch is required", + }) + return + } + + gitOp := s.procMgr.GitOperator() + result, err := gitOp.Merge(c.Request.Context(), req.BaseBranch) + if err != nil { + s.handleGitError(c, "merge", err) + return + } + + c.JSON(http.StatusOK, result) +} + +// handleGitAbort handles POST /api/v1/git/abort +func (s *Server) handleGitAbort(c *gin.Context) { + var req GitAbortRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, process.GitOperationResult{ + Success: false, + Operation: "abort", + Error: "invalid request: " + err.Error(), + }) + return + } + + if req.Operation != "merge" && req.Operation != "rebase" { + c.JSON(http.StatusBadRequest, process.GitOperationResult{ + Success: false, + Operation: "abort", + Error: "operation must be 'merge' or 'rebase'", + }) + return + } + + gitOp := s.procMgr.GitOperator() + result, err := gitOp.Abort(c.Request.Context(), req.Operation) + if err != nil { + s.handleGitError(c, "abort", err) + return + } + + c.JSON(http.StatusOK, result) +} + +// handleGitCommit handles POST /api/v1/git/commit +func (s *Server) handleGitCommit(c *gin.Context) { + var req GitCommitRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, process.GitOperationResult{ + Success: false, + Operation: "commit", + Error: "invalid request: " + err.Error(), + }) + return + } + + if req.Message == "" { + c.JSON(http.StatusBadRequest, process.GitOperationResult{ + Success: false, + Operation: "commit", + Error: "message is required", + }) + return + } + + gitOp := s.procMgr.GitOperator() + result, err := gitOp.Commit(c.Request.Context(), req.Message, req.StageAll) + if err != nil { + s.handleGitError(c, "commit", err) + return + } + + c.JSON(http.StatusOK, result) +} + +// handleGitStage handles POST /api/v1/git/stage +func (s *Server) handleGitStage(c *gin.Context) { + var req GitStageRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, process.GitOperationResult{ + Success: false, + Operation: "stage", + Error: "invalid request: " + err.Error(), + }) + return + } + + gitOp := s.procMgr.GitOperator() + result, err := gitOp.Stage(c.Request.Context(), req.Paths) + if err != nil { + s.handleGitError(c, "stage", 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 + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, process.PRCreateResult{ + Success: false, + Error: "invalid request: " + err.Error(), + }) + return + } + + if req.Title == "" { + c.JSON(http.StatusBadRequest, process.PRCreateResult{ + Success: false, + Error: "title is required", + }) + return + } + + gitOp := s.procMgr.GitOperator() + result, err := gitOp.CreatePR(c.Request.Context(), req.Title, req.Body, req.BaseBranch) + if err != nil { + if errors.Is(err, process.ErrOperationInProgress) { + c.JSON(http.StatusConflict, process.PRCreateResult{ + Success: false, + Error: "another git operation is already in progress", + }) + return + } + s.logger.Error("git create-pr failed", zap.Error(err)) + c.JSON(http.StatusInternalServerError, process.PRCreateResult{ + Success: false, + Error: err.Error(), + }) + return + } + + c.JSON(http.StatusOK, result) +} + +// handleGitError handles errors from git operations. +func (s *Server) handleGitError(c *gin.Context, operation string, err error) { + if errors.Is(err, process.ErrOperationInProgress) { + c.JSON(http.StatusConflict, process.GitOperationResult{ + Success: false, + Operation: operation, + Error: "another git operation is already in progress", + }) + return + } + + s.logger.Error("git operation failed", zap.String("operation", operation), zap.Error(err)) + c.JSON(http.StatusInternalServerError, process.GitOperationResult{ + Success: false, + Operation: operation, + Error: err.Error(), + }) +} + diff --git a/apps/backend/internal/agentctl/server/api/server.go b/apps/backend/internal/agentctl/server/api/server.go index 5dbaad53a2..99a16489d2 100644 --- a/apps/backend/internal/agentctl/server/api/server.go +++ b/apps/backend/internal/agentctl/server/api/server.go @@ -84,6 +84,16 @@ func (s *Server) setupRoutes() { // Shell access (HTTP endpoints only - streaming is via /workspace/stream) api.GET("/shell/status", s.handleShellStatus) api.GET("/shell/buffer", s.handleShellBuffer) + + // Git operations + api.POST("/git/pull", s.handleGitPull) + api.POST("/git/push", s.handleGitPush) + api.POST("/git/rebase", s.handleGitRebase) + api.POST("/git/merge", s.handleGitMerge) + api.POST("/git/abort", s.handleGitAbort) + api.POST("/git/commit", s.handleGitCommit) + api.POST("/git/stage", s.handleGitStage) + api.POST("/git/create-pr", s.handleGitCreatePR) } } diff --git a/apps/backend/internal/agentctl/server/process/git.go b/apps/backend/internal/agentctl/server/process/git.go new file mode 100644 index 0000000000..84538d42d1 --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/git.go @@ -0,0 +1,543 @@ +// Package process provides git operation execution for agentctl. +package process + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + + "github.com/kandev/kandev/internal/common/logger" + "go.uber.org/zap" +) + +// ErrOperationInProgress is returned when a git operation is already in progress. +var ErrOperationInProgress = errors.New("git operation already in progress") + +// GitOperationResult represents the result of a git operation. +type GitOperationResult struct { + Success bool `json:"success"` + Operation string `json:"operation"` + Output string `json:"output"` + Error string `json:"error,omitempty"` + ConflictFiles []string `json:"conflict_files,omitempty"` +} + +// GitOperator executes git operations in a workspace directory. +type GitOperator struct { + workDir string + logger *logger.Logger + + mu sync.Mutex // Prevents concurrent git operations + inProgress bool + currentOp string +} + +// NewGitOperator creates a new GitOperator for the given workspace directory. +func NewGitOperator(workDir string, log *logger.Logger) *GitOperator { + return &GitOperator{ + workDir: workDir, + logger: log.WithFields(zap.String("component", "git-operator")), + } +} + +// runGitCommand executes a git command in the workDir +func (g *GitOperator) runGitCommand(ctx context.Context, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Dir = g.workDir + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + g.logger.Debug("executing git command", zap.Strings("args", args)) + + err := cmd.Run() + output := stdout.String() + if stderr.Len() > 0 { + if output != "" { + output += "\n" + } + output += stderr.String() + } + + if err != nil { + return output, fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String())) + } + + return output, nil +} + +// filterGitEnv removes GIT_DIR and GIT_WORK_TREE from the environment. +// This ensures that external tools like gh CLI correctly detect the repository +// from the working directory, which is essential for worktrees where these +// env vars could point to the wrong location. +func filterGitEnv(env []string) []string { + result := make([]string, 0, len(env)) + for _, e := range env { + if strings.HasPrefix(e, "GIT_DIR=") || strings.HasPrefix(e, "GIT_WORK_TREE=") { + continue + } + result = append(result, e) + } + return result +} + +// triggerFsNotify creates and removes a temporary file to trigger fsnotify and refresh git status. +// This is OS-agnostic and reliably triggers filesystem events that the workspace tracker watches. +// Called after git operations like commit, push, pull, etc. to refresh the UI. +func (g *GitOperator) triggerFsNotify() { + sentinelPath := filepath.Join(g.workDir, ".git-op-complete") + f, err := os.Create(sentinelPath) + if err != nil { + g.logger.Debug("failed to create sentinel file", zap.Error(err)) + return + } + _ = f.Close() + if err := os.Remove(sentinelPath); err != nil { + g.logger.Debug("failed to remove sentinel file", zap.Error(err)) + } +} + +// getCurrentBranch returns the current branch name +func (g *GitOperator) getCurrentBranch(ctx context.Context) (string, error) { + output, err := g.runGitCommand(ctx, "rev-parse", "--abbrev-ref", "HEAD") + if err != nil { + return "", fmt.Errorf("failed to get current branch: %w", err) + } + return strings.TrimSpace(output), nil +} + +// hasUncommittedChanges checks if there are uncommitted changes +func (g *GitOperator) hasUncommittedChanges(ctx context.Context) (bool, error) { + output, err := g.runGitCommand(ctx, "status", "--porcelain") + if err != nil { + return false, fmt.Errorf("failed to check uncommitted changes: %w", err) + } + return strings.TrimSpace(output) != "", nil +} + +// parseConflictFiles parses conflict file names from git output +func (g *GitOperator) parseConflictFiles(output string) []string { + var conflicts []string + lines := strings.Split(output, "\n") + + for _, line := range lines { + line = strings.TrimSpace(line) + // Look for "CONFLICT" markers in git output + if strings.HasPrefix(line, "CONFLICT") { + // Extract file name from patterns like: + // "CONFLICT (content): Merge conflict in " + // "CONFLICT (add/add): Merge conflict in " + if idx := strings.Index(line, "Merge conflict in "); idx != -1 { + file := strings.TrimSpace(line[idx+len("Merge conflict in "):]) + if file != "" { + conflicts = append(conflicts, file) + } + } + } + } + + return conflicts +} + +// Pull performs a git pull operation. +func (g *GitOperator) Pull(ctx context.Context, rebase bool) (*GitOperationResult, error) { + if !g.tryLock("pull") { + return nil, ErrOperationInProgress + } + defer g.unlock() + + result := &GitOperationResult{ + Operation: "pull", + } + + branch, err := g.getCurrentBranch(ctx) + if err != nil { + result.Error = err.Error() + return result, nil + } + + var args []string + if rebase { + args = []string{"pull", "--rebase", "origin", branch} + } else { + args = []string{"pull", "origin", branch} + } + + output, err := g.runGitCommand(ctx, args...) + result.Output = output + + if err != nil { + result.Error = err.Error() + result.ConflictFiles = g.parseConflictFiles(output) + + // For rebase conflicts, auto-abort to restore clean state + if rebase && len(result.ConflictFiles) > 0 { + g.logger.Info("rebase conflict detected, aborting rebase") + if _, abortErr := g.runGitCommand(ctx, "rebase", "--abort"); abortErr != nil { + g.logger.Warn("failed to abort rebase", zap.Error(abortErr)) + } + } + return result, nil + } + + result.Success = true + g.logger.Info("pull completed", zap.String("branch", branch), zap.Bool("rebase", rebase)) + return result, nil +} + +// Push performs a git push operation. +func (g *GitOperator) Push(ctx context.Context, force bool, setUpstream bool) (*GitOperationResult, error) { + if !g.tryLock("push") { + return nil, ErrOperationInProgress + } + defer g.unlock() + + result := &GitOperationResult{ + Operation: "push", + } + + branch, err := g.getCurrentBranch(ctx) + if err != nil { + result.Error = err.Error() + return result, nil + } + + args := []string{"push"} + + if setUpstream { + args = append(args, "--set-upstream") + } + + if force { + // Use --force-with-lease for safer force push + args = append(args, "--force-with-lease") + } + + args = append(args, "origin", branch) + + output, err := g.runGitCommand(ctx, args...) + result.Output = output + + if err != nil { + result.Error = err.Error() + return result, nil + } + + result.Success = true + g.logger.Info("push completed", zap.String("branch", branch), zap.Bool("force", force), zap.Bool("set_upstream", setUpstream)) + return result, nil +} + +// Rebase performs a git rebase onto the specified base branch. +func (g *GitOperator) Rebase(ctx context.Context, baseBranch string) (*GitOperationResult, error) { + if !g.tryLock("rebase") { + return nil, ErrOperationInProgress + } + defer g.unlock() + + result := &GitOperationResult{ + Operation: "rebase", + } + + // Fetch the base branch first + fetchOutput, err := g.runGitCommand(ctx, "fetch", "origin", baseBranch) + if err != nil { + result.Error = fmt.Sprintf("failed to fetch base branch: %s", err.Error()) + result.Output = fetchOutput + return result, nil + } + + // Perform the rebase + rebaseOutput, err := g.runGitCommand(ctx, "rebase", "origin/"+baseBranch) + result.Output = fetchOutput + rebaseOutput + + if err != nil { + result.Error = err.Error() + result.ConflictFiles = g.parseConflictFiles(rebaseOutput) + + // Auto-abort rebase on conflicts to restore clean state + if len(result.ConflictFiles) > 0 { + g.logger.Info("rebase conflict detected, aborting rebase") + if _, abortErr := g.runGitCommand(ctx, "rebase", "--abort"); abortErr != nil { + g.logger.Warn("failed to abort rebase", zap.Error(abortErr)) + } + } + return result, nil + } + + result.Success = true + g.logger.Info("rebase completed", zap.String("base_branch", baseBranch)) + return result, nil +} + +// Merge performs a git merge of the specified base branch. +func (g *GitOperator) Merge(ctx context.Context, baseBranch string) (*GitOperationResult, error) { + if !g.tryLock("merge") { + return nil, ErrOperationInProgress + } + defer g.unlock() + + result := &GitOperationResult{ + Operation: "merge", + } + + // Fetch the base branch first + fetchOutput, err := g.runGitCommand(ctx, "fetch", "origin", baseBranch) + if err != nil { + result.Error = fmt.Sprintf("failed to fetch base branch: %s", err.Error()) + result.Output = fetchOutput + return result, nil + } + + // Perform the merge + mergeOutput, err := g.runGitCommand(ctx, "merge", "origin/"+baseBranch) + result.Output = fetchOutput + mergeOutput + + if err != nil { + result.Error = err.Error() + result.ConflictFiles = g.parseConflictFiles(mergeOutput) + // For merge conflicts, leave in place so user can resolve + // Do NOT auto-abort like we do for rebase + return result, nil + } + + result.Success = true + g.logger.Info("merge completed", zap.String("base_branch", baseBranch)) + return result, nil +} + +// Commit creates a git commit with the specified message. +// If stageAll is true, it stages all changes before committing. +func (g *GitOperator) Commit(ctx context.Context, message string, stageAll bool) (*GitOperationResult, error) { + if !g.tryLock("commit") { + return nil, ErrOperationInProgress + } + defer g.unlock() + + result := &GitOperationResult{ + Operation: "commit", + } + + // Check if there are changes to commit + hasChanges, err := g.hasUncommittedChanges(ctx) + if err != nil { + result.Error = err.Error() + return result, nil + } + + if !hasChanges { + result.Error = "no changes to commit" + return result, nil + } + + // Stage all changes if requested + if stageAll { + stageOutput, err := g.runGitCommand(ctx, "add", "-A") + if err != nil { + result.Error = fmt.Sprintf("failed to stage changes: %s", err.Error()) + result.Output = stageOutput + return result, nil + } + result.Output = stageOutput + } + + // Create the commit + commitOutput, err := g.runGitCommand(ctx, "commit", "-m", message) + result.Output += commitOutput + + if err != nil { + result.Error = err.Error() + return result, nil + } + + result.Success = true + g.logger.Info("commit completed", zap.String("message", message)) + return result, nil +} + +// Stage stages files for commit using git add. +// If paths is empty, stages all changes (git add -A). +func (g *GitOperator) Stage(ctx context.Context, paths []string) (*GitOperationResult, error) { + if !g.tryLock("stage") { + return nil, ErrOperationInProgress + } + defer g.unlock() + + result := &GitOperationResult{ + Operation: "stage", + } + + var args []string + if len(paths) == 0 { + // Stage all changes + args = []string{"add", "-A"} + } else { + // Stage specific files + args = append([]string{"add", "--"}, paths...) + } + + output, err := g.runGitCommand(ctx, args...) + result.Output = output + + if err != nil { + result.Error = err.Error() + return result, nil + } + + result.Success = true + g.logger.Info("stage 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") { + return nil, ErrOperationInProgress + } + defer g.unlock() + + result := &GitOperationResult{ + Operation: "abort", + } + + var args []string + switch operation { + case "merge": + args = []string{"merge", "--abort"} + case "rebase": + args = []string{"rebase", "--abort"} + default: + result.Error = fmt.Sprintf("unsupported operation to abort: %s (must be 'merge' or 'rebase')", operation) + return result, nil + } + + output, err := g.runGitCommand(ctx, args...) + result.Output = output + + if err != nil { + result.Error = err.Error() + return result, nil + } + + result.Success = true + g.logger.Info("abort completed", zap.String("operation", operation)) + return result, nil +} + + + +// tryLock attempts to acquire the operation lock without blocking. +// Returns true if the lock was acquired, false if an operation is in progress. +func (g *GitOperator) tryLock(opName string) bool { + g.mu.Lock() + defer g.mu.Unlock() + if g.inProgress { + return false + } + g.inProgress = true + g.currentOp = opName + return true +} + +// unlock releases the operation lock and triggers a git status refresh. +func (g *GitOperator) unlock() { + g.mu.Lock() + defer g.mu.Unlock() + g.inProgress = false + g.currentOp = "" + + // Trigger fsnotify to refresh git status in the workspace tracker. + // This is called after every git operation completes. + g.triggerFsNotify() +} + +// PRCreateResult represents the result of a PR creation operation. +type PRCreateResult struct { + Success bool `json:"success"` + PRURL string `json:"pr_url,omitempty"` + Output string `json:"output,omitempty"` + Error string `json:"error,omitempty"` +} + +// CreatePR creates a pull request using the gh CLI. +// It first pushes the current branch to the remote, then creates the PR. +func (g *GitOperator) CreatePR(ctx context.Context, title, body, baseBranch string) (*PRCreateResult, error) { + if !g.tryLock("create-pr") { + return nil, ErrOperationInProgress + } + defer g.unlock() + + result := &PRCreateResult{} + + // Get current branch name for --head flag + branch, err := g.getCurrentBranch(ctx) + if err != nil { + result.Error = fmt.Sprintf("failed to get current branch: %s", err.Error()) + return result, nil + } + g.logger.Debug("current branch", zap.String("branch", branch)) + + // First, push the branch to remote (with --set-upstream in case it's a new branch) + pushOutput, err := g.runGitCommand(ctx, "push", "--set-upstream", "origin", "HEAD") + if err != nil { + result.Error = fmt.Sprintf("failed to push branch: %s", pushOutput) + result.Output = pushOutput + return result, nil + } + g.logger.Debug("pushed branch to remote", zap.String("output", pushOutput)) + + // Now create the PR + // Use --head to explicitly specify the branch (helps gh in worktree scenarios) + args := []string{"pr", "create", "--title", title, "--body", body, "--head", branch} + + // Strip remote prefix (e.g., "origin/main" -> "main") since gh expects just the branch name + cleanBaseBranch := strings.TrimPrefix(baseBranch, "origin/") + if cleanBaseBranch != "" { + args = append(args, "--base", cleanBaseBranch) + } + + cmd := exec.CommandContext(ctx, "gh", args...) + cmd.Dir = g.workDir + + // Clear GIT_DIR and GIT_WORK_TREE to ensure gh uses the worktree's .git file + // and correctly detects the current branch. Inherited env vars can confuse gh + // when running from a worktree. + env := filterGitEnv(os.Environ()) + cmd.Env = env + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + g.logger.Debug("executing gh command", + zap.Strings("args", args), + zap.String("workDir", g.workDir)) + + err = cmd.Run() + output := stdout.String() + if stderr.Len() > 0 { + if output != "" { + output += "\n" + } + output += stderr.String() + } + result.Output = output + + if err != nil { + result.Error = fmt.Sprintf("%s: %s", err.Error(), strings.TrimSpace(stderr.String())) + return result, nil + } + + // Extract PR URL from output (gh pr create outputs the URL on success) + result.PRURL = strings.TrimSpace(stdout.String()) + result.Success = true + g.logger.Info("PR created", zap.String("url", result.PRURL)) + return result, nil +} diff --git a/apps/backend/internal/agentctl/server/process/manager.go b/apps/backend/internal/agentctl/server/process/manager.go index 615a69b5e9..bf90cf8c6d 100644 --- a/apps/backend/internal/agentctl/server/process/manager.go +++ b/apps/backend/internal/agentctl/server/process/manager.go @@ -88,6 +88,10 @@ type Manager struct { pendingPermissions map[string]*PendingPermission permissionMu sync.RWMutex + // Git operator for git operations (lazy-initialized) + gitOperator *GitOperator + gitOperatorMu sync.Mutex + // Synchronization mu sync.RWMutex wg sync.WaitGroup @@ -135,6 +139,18 @@ func (m *Manager) GetWorkspaceTracker() *WorkspaceTracker { return m.workspaceTracker } +// GitOperator returns the git operator for git operations. +// The operator is lazy-initialized on first call. +func (m *Manager) GitOperator() *GitOperator { + m.gitOperatorMu.Lock() + defer m.gitOperatorMu.Unlock() + + if m.gitOperator == nil { + m.gitOperator = NewGitOperator(m.cfg.WorkDir, m.logger) + } + return m.gitOperator +} + // Start starts the agent process func (m *Manager) Start(ctx context.Context) error { m.startMu.Lock() diff --git a/apps/backend/internal/agentctl/server/process/workspace_tracker.go b/apps/backend/internal/agentctl/server/process/workspace_tracker.go index f2f3de3a26..5b2747bf44 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_tracker.go +++ b/apps/backend/internal/agentctl/server/process/workspace_tracker.go @@ -221,35 +221,71 @@ func (wt *WorkspaceTracker) getGitStatus(ctx context.Context) (types.GitStatusUp } // Parse porcelain output + // Git status --porcelain format: XY filename + // X = index (staged) status, Y = working tree (unstaged) status + // ' ' = unmodified, M = modified, A = added, D = deleted, R = renamed, ? = untracked lines := strings.Split(string(statusOut), "\n") for _, line := range lines { - if len(line) < 4 { + if len(line) < 3 { continue } - statusCode := line[:2] + indexStatus := line[0] // Staged status (X) + workTreeStatus := line[1] // Unstaged status (Y) filePath := strings.TrimSpace(line[3:]) fileInfo := types.FileInfo{ Path: filePath, } - // Parse status code + // Determine staged status based on index and worktree status. + // A file's change is "staged" if the relevant change is in the index. + // If there's a worktree change (like deletion), that change is unstaged. + // + // Examples: + // "M " = modified and staged (Staged=true) + // " M" = modified but unstaged (Staged=false) + // "MM" = modified staged + more unstaged modifications (Staged=false for the current state) + // "A " = added and staged (Staged=true) + // "AD" = added to index but deleted in worktree - deletion is unstaged (Staged=false) + // "D " = deleted and staged (Staged=true) + // " D" = deleted but unstaged (Staged=false) + + // Parse status - prioritize worktree changes as they represent the current state switch { - case strings.HasPrefix(statusCode, "M ") || strings.HasPrefix(statusCode, " M"): - fileInfo.Status = "modified" - update.Modified = append(update.Modified, filePath) - case strings.HasPrefix(statusCode, "A "): - fileInfo.Status = "added" - update.Added = append(update.Added, filePath) - case strings.HasPrefix(statusCode, "D ") || strings.HasPrefix(statusCode, " D"): - fileInfo.Status = "deleted" - update.Deleted = append(update.Deleted, filePath) - case strings.HasPrefix(statusCode, "??"): + case indexStatus == '?' && workTreeStatus == '?': fileInfo.Status = "untracked" + fileInfo.Staged = false update.Untracked = append(update.Untracked, filePath) - case strings.HasPrefix(statusCode, "R "): + case workTreeStatus == 'D': + // File deleted in worktree - this is an unstaged deletion + // Even if there were staged changes before (A, M), the deletion is unstaged + fileInfo.Status = "deleted" + fileInfo.Staged = false + update.Deleted = append(update.Deleted, filePath) + case indexStatus == 'D': + // File deleted and staged (no worktree status means deletion is complete) + fileInfo.Status = "deleted" + fileInfo.Staged = true + update.Deleted = append(update.Deleted, filePath) + case workTreeStatus == 'M': + // Modified in worktree - unstaged modification + fileInfo.Status = "modified" + fileInfo.Staged = false + update.Modified = append(update.Modified, filePath) + case indexStatus == 'M': + // Modified and staged (no worktree changes) + fileInfo.Status = "modified" + fileInfo.Staged = true + update.Modified = append(update.Modified, filePath) + case indexStatus == 'A': + // Added and staged + fileInfo.Status = "added" + fileInfo.Staged = true + update.Added = append(update.Added, filePath) + case indexStatus == 'R': fileInfo.Status = "renamed" + fileInfo.Staged = true // Renamed files have format "old -> new" if idx := strings.Index(filePath, " -> "); idx != -1 { fileInfo.OldPath = filePath[:idx] @@ -302,7 +338,9 @@ func (wt *WorkspaceTracker) enrichWithDiffData(ctx context.Context, update *type deletions, _ := strconv.Atoi(parts[1]) filePath := parts[2] - // Update existing file info if it exists + // Only update file info if it exists in status (uncommitted changes). + // Files that appear in diff but not in status are committed changes - we don't + // add them to the Files map as that would make git status show already-committed files. if fileInfo, exists := update.Files[filePath]; exists { fileInfo.Additions = additions fileInfo.Deletions = deletions @@ -315,26 +353,10 @@ func (wt *WorkspaceTracker) enrichWithDiffData(ctx context.Context, update *type } update.Files[filePath] = fileInfo - } else { - // File might be committed but not in status (because it's committed) - // Add it to the Files map - fileInfo := types.FileInfo{ - Path: filePath, - Status: "modified", // Assume modified if it has a diff - Additions: additions, - Deletions: deletions, - } - - // Get the actual diff content for this file - diffCmd := exec.CommandContext(ctx, "git", "diff", baseRef, "--", filePath) - diffCmd.Dir = wt.workDir - if diffOut, err := diffCmd.Output(); err == nil { - fileInfo.Diff = string(diffOut) - } - - update.Files[filePath] = fileInfo - update.Modified = append(update.Modified, filePath) } + // NOTE: We intentionally do NOT add files that are in the diff but not in git status. + // Those are committed changes, not uncommitted changes. The UI should only show + // uncommitted changes in the "files changed" count. } // Also check for staged changes diff --git a/apps/backend/internal/agentctl/types/streams/git.go b/apps/backend/internal/agentctl/types/streams/git.go index f9e43ad125..a0b567c4e8 100644 --- a/apps/backend/internal/agentctl/types/streams/git.go +++ b/apps/backend/internal/agentctl/types/streams/git.go @@ -49,6 +49,10 @@ type FileInfo struct { // Status indicates the file status: "modified", "added", "deleted", "untracked", "renamed". Status string `json:"status"` + // Staged indicates whether the file changes are staged (in the index). + // If false, the changes are unstaged (in the working tree only). + Staged bool `json:"staged"` + // Additions is the number of added lines. Additions int `json:"additions,omitempty"` diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 80ed2cf1ad..982a4d7454 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -164,6 +164,16 @@ const ( ActionShellInput = "shell.input" // Send input to shell ActionSessionShellOutput = "session.shell.output" // Shell output notification (also used for exit with type: "exit") + // Git worktree actions + ActionWorktreePull = "worktree.pull" // Pull from remote + ActionWorktreePush = "worktree.push" // Push to remote + ActionWorktreeRebase = "worktree.rebase" // Rebase onto base branch + ActionWorktreeMerge = "worktree.merge" // Merge base branch into worktree + ActionWorktreeAbort = "worktree.abort" // Abort in-progress merge or rebase + ActionWorktreeCommit = "worktree.commit" // Commit changes + ActionWorktreeStage = "worktree.stage" // Stage files for commit + ActionWorktreeCreatePR = "worktree.create_pr" // Create a pull request + // User actions ActionUserGet = "user.get" ActionUserSettingsUpdate = "user.settings.update" diff --git a/apps/web/components/task/task-files-panel.tsx b/apps/web/components/task/task-files-panel.tsx index 66141f5273..e17326db5b 100644 --- a/apps/web/components/task/task-files-panel.tsx +++ b/apps/web/components/task/task-files-panel.tsx @@ -7,6 +7,7 @@ import { IconPlus, IconCircleFilled, IconMinus, + IconCheck, } from '@tabler/icons-react'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@kandev/ui/tabs'; @@ -15,6 +16,7 @@ import { LineStat } from '@/components/diff-stat'; import { useAppStore } from '@/components/state-provider'; import { useOpenSessionInEditor } from '@/hooks/use-open-session-in-editor'; import { useSessionGitStatus } from '@/hooks/use-session-git-status'; +import { useGitOperations } from '@/hooks/use-git-operations'; import type { FileInfo } from '@/lib/state/store'; import { FileBrowser } from '@/components/task/file-browser'; @@ -81,6 +83,7 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiffPath, onOpenFi const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); const gitStatus = useSessionGitStatus(activeSessionId); const openEditor = useOpenSessionInEditor(activeSessionId ?? null); + const gitOps = useGitOperations(activeSessionId ?? null); // Convert git status files to array for display const changedFiles = useMemo(() => { @@ -90,6 +93,7 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiffPath, onOpenFi return Object.values(gitStatus.files).map((file) => ({ path: file.path, status: file.status, + staged: file.staged, plus: file.additions, minus: file.deletions, oldPath: file.old_path, @@ -123,17 +127,46 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiffPath, onOpenFi className="group flex items-center justify-between gap-3 text-sm rounded-md px-1 py-0.5 -mx-1 hover:bg-muted/60 cursor-pointer" onClick={() => onSelectDiffPath(file.path)} > - + + Stage file + )} - + +
diff --git a/apps/web/components/task/task-top-bar.tsx b/apps/web/components/task/task-top-bar.tsx index 19412328ba..81bb9cadc2 100644 --- a/apps/web/components/task/task-top-bar.tsx +++ b/apps/web/components/task/task-top-bar.tsx @@ -1,10 +1,9 @@ 'use client'; -import { memo, useState } from 'react'; +import { memo, useState, useCallback } from 'react'; import Link from 'next/link'; import { IconArrowLeft, - IconArrowUp, IconCopy, IconDots, IconFolderOpen, @@ -13,20 +12,39 @@ import { IconGitPullRequest, IconChevronDown, IconCheck, + IconLoader2, + IconCloudDownload, + IconCloudUpload, + IconGitCherryPick, + IconGitCommit, } from '@tabler/icons-react'; import { Button } from '@kandev/ui/button'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogClose, +} from '@kandev/ui/dialog'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, + DropdownMenuSeparator, } from '@kandev/ui/dropdown-menu'; +import { Textarea } from '@kandev/ui/textarea'; import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; import { Popover, PopoverContent, PopoverTrigger } from '@kandev/ui/popover'; +import { Checkbox } from '@kandev/ui/checkbox'; +import { Label } from '@kandev/ui/label'; import { CommitStatBadge, LineStat } from '@/components/diff-stat'; import { useSessionGitStatus } from '@/hooks/use-session-git-status'; +import { useGitOperations } from '@/hooks/use-git-operations'; import { formatUserHomePath } from '@/lib/utils'; import { EditorsMenu } from '@/components/task/editors-menu'; +import { useToast } from '@/components/toast-provider'; type TaskTopBarProps = { taskId?: string | null; @@ -57,8 +75,16 @@ const TaskTopBar = memo(function TaskTopBar({ const [popoverOpen, setPopoverOpen] = useState(false); const [copiedRepo, setCopiedRepo] = useState(false); const [copiedWorktree, setCopiedWorktree] = useState(false); + const [commitDialogOpen, setCommitDialogOpen] = useState(false); + const [commitMessage, setCommitMessage] = useState(''); + const [stageAll, setStageAll] = useState(true); + const [prDialogOpen, setPrDialogOpen] = useState(false); + const [prTitle, setPrTitle] = useState(''); + const [prBody, setPrBody] = useState(''); + const { toast } = useToast(); const gitStatus = useSessionGitStatus(activeSessionId ?? null); + const { pull, push, rebase, merge, commit, createPR, isLoading: isGitLoading } = useGitOperations(activeSessionId ?? null); // Use worktree branch if available, otherwise fall back to base branch const displayBranch = worktreeBranch || baseBranch; @@ -73,6 +99,109 @@ const TaskTopBar = memo(function TaskTopBar({ } } + const handleGitOperation = useCallback(async ( + operation: () => Promise<{ success: boolean; output: string; error?: string; conflict_files?: string[] }>, + operationName: string + ) => { + try { + const result = await operation(); + if (result.success) { + toast({ + title: `${operationName} successful`, + description: result.output.slice(0, 200) || `${operationName} completed successfully`, + variant: 'success', + }); + } else { + toast({ + title: `${operationName} failed`, + description: result.error || 'An error occurred', + variant: 'error', + }); + if (result.conflict_files && result.conflict_files.length > 0) { + console.log('Conflict files:', result.conflict_files); + } + } + } catch (error) { + toast({ + title: `${operationName} failed`, + description: error instanceof Error ? error.message : 'An unexpected error occurred', + variant: 'error', + }); + } + }, [toast]); + + const handlePull = useCallback(() => { + handleGitOperation(() => pull(), 'Pull'); + }, [handleGitOperation, pull]); + + const handlePush = useCallback(() => { + handleGitOperation(() => push(), 'Push'); + }, [handleGitOperation, push]); + + const handleRebase = useCallback(() => { + // Rebase onto the base branch (e.g., origin/main) + const targetBranch = baseBranch || 'origin/main'; + handleGitOperation(() => rebase(targetBranch), 'Rebase'); + }, [handleGitOperation, rebase, baseBranch]); + + const handleMerge = useCallback(() => { + // Merge from the base branch (e.g., origin/main) + const targetBranch = baseBranch || 'origin/main'; + handleGitOperation(() => merge(targetBranch), 'Merge'); + }, [handleGitOperation, merge, baseBranch]); + + const handleOpenCommitDialog = useCallback(() => { + setCommitMessage(''); + setStageAll(true); + setCommitDialogOpen(true); + }, []); + + const handleCommit = useCallback(async () => { + if (!commitMessage.trim()) return; + setCommitDialogOpen(false); + await handleGitOperation(() => commit(commitMessage.trim(), stageAll), 'Commit'); + setCommitMessage(''); + }, [commitMessage, stageAll, handleGitOperation, commit]); + + const handleOpenPRDialog = useCallback(() => { + setPrTitle(taskTitle || ''); + setPrBody(''); + setPrDialogOpen(true); + }, [taskTitle]); + + const handleCreatePR = useCallback(async () => { + if (!prTitle.trim()) return; + setPrDialogOpen(false); + try { + const result = await createPR(prTitle.trim(), prBody.trim(), baseBranch); + if (result.success) { + toast({ + title: 'Pull Request created', + description: result.pr_url || 'PR created successfully', + variant: 'success', + }); + // Open the PR URL in a new tab + if (result.pr_url) { + window.open(result.pr_url, '_blank'); + } + } else { + toast({ + title: 'Create PR failed', + description: result.error || 'An error occurred', + variant: 'error', + }); + } + } catch (e) { + toast({ + title: 'Create PR failed', + description: e instanceof Error ? e.message : 'An error occurred', + variant: 'error', + }); + } + setPrTitle(''); + setPrBody(''); + }, [prTitle, prBody, baseBranch, createPR, toast]); + const handleBranchClick = (e: React.MouseEvent) => { e.stopPropagation(); if (displayBranch) { @@ -239,71 +368,100 @@ const TaskTopBar = memo(function TaskTopBar({
- {/* Create PR Split Button */} + {/* Commit Split Button */}
- Create pull request + + {gitStatus?.files && Object.keys(gitStatus.files).length > 0 + ? `Commit ${Object.keys(gitStatus.files).length} changed file${Object.keys(gitStatus.files).length !== 1 ? 's' : ''}` + : 'No changes to commit'} + - - + { - // TODO: Implement pull - console.log('Pull from remote'); - }} + className="cursor-pointer gap-3" + onClick={handleOpenPRDialog} + disabled={isGitLoading || !activeSessionId} > - - Pull + + Create PR + + + + + Pull + {(gitStatus?.behind ?? 0) > 0 && ( + + ↓{gitStatus?.behind} + + )} { - // TODO: Implement rebase - console.log('Rebase'); - }} + className="cursor-pointer gap-3" + onClick={handlePush} + disabled={isGitLoading || !activeSessionId} > - - Rebase + + Push + {(gitStatus?.ahead ?? 0) > 0 && ( + + ↑{gitStatus?.ahead} + + )} + + + + + Rebase + onto {baseBranch || 'origin/main'} { - // TODO: Implement push - console.log('Push to remote'); - }} + className="cursor-pointer gap-3" + onClick={handleMerge} + disabled={isGitLoading || !activeSessionId} > - - Push - - { - // TODO: Implement merge - console.log('Merge'); - }} - > - - Merge + + Merge + from {baseBranch || 'origin/main'} @@ -343,6 +501,149 @@ const TaskTopBar = memo(function TaskTopBar({
+ + {/* Commit Dialog */} + + + + + + Commit Changes + + +
+
+ {gitStatus?.files && Object.keys(gitStatus.files).length > 0 ? ( + + {Object.keys(gitStatus.files).length} file{Object.keys(gitStatus.files).length !== 1 ? 's' : ''} changed + {(totalAdditions > 0 || totalDeletions > 0) && ( + + (+{totalAdditions} + {' / '} + -{totalDeletions}) + + )} + + ) : ( + No changes to commit + )} +
+