feat: Add PR creation via gh CLI and improve git operations (#57)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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/<baseBranch>, 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/<baseBranch>, 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
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <file>"
|
||||
// "CONFLICT (add/add): Merge conflict in <file>"
|
||||
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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
<button type="button" className="min-w-0 text-left cursor-pointer">
|
||||
<p className="truncate text-foreground">
|
||||
<span className="text-foreground/60">{folder}/</span>
|
||||
<span className="font-medium text-foreground">{name}</span>
|
||||
</p>
|
||||
{file.oldPath && (
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
Renamed from: {file.oldPath}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{/* Staged indicator */}
|
||||
{file.staged ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center justify-center h-4 w-4 rounded bg-emerald-500/20 text-emerald-600">
|
||||
<IconCheck className="h-3 w-3" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Staged for commit</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center h-4 w-4 rounded border border-dashed border-muted-foreground/50 text-muted-foreground hover:border-emerald-500 hover:text-emerald-500 hover:bg-emerald-500/10"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void gitOps.stage([file.path]);
|
||||
}}
|
||||
>
|
||||
<IconPlus className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Stage file</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</button>
|
||||
<button type="button" className="min-w-0 text-left cursor-pointer">
|
||||
<p className="truncate text-foreground">
|
||||
<span className="text-foreground/60">{folder}/</span>
|
||||
<span className="font-medium text-foreground">{name}</span>
|
||||
</p>
|
||||
{file.oldPath && (
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
Renamed from: {file.oldPath}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100">
|
||||
<Tooltip>
|
||||
|
||||
@@ -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({
|
||||
<div className="flex items-center gap-2">
|
||||
<EditorsMenu activeSessionId={activeSessionId ?? null} />
|
||||
|
||||
{/* Create PR Split Button */}
|
||||
{/* Commit Split Button */}
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="rounded-none border-0 cursor-pointer"
|
||||
onClick={() => {
|
||||
// TODO: Implement create PR
|
||||
console.log('Create PR');
|
||||
}}
|
||||
className={`rounded-none border-0 cursor-pointer ${
|
||||
gitStatus?.files && Object.keys(gitStatus.files).length > 0
|
||||
? 'bg-amber-500/10 text-amber-600 dark:text-amber-400 hover:bg-amber-500/20'
|
||||
: ''
|
||||
}`}
|
||||
onClick={handleOpenCommitDialog}
|
||||
disabled={isGitLoading || !activeSessionId}
|
||||
>
|
||||
<IconGitPullRequest className="h-4 w-4" />
|
||||
Create PR
|
||||
<IconGitCommit className={`h-4 w-4 ${gitStatus?.files && Object.keys(gitStatus.files).length > 0 ? 'text-amber-500' : ''}`} />
|
||||
Commit
|
||||
{gitStatus?.files && Object.keys(gitStatus.files).length > 0 && (
|
||||
<span className="ml-1 rounded-full bg-amber-500/20 px-1.5 py-0.5 text-xs font-medium">
|
||||
{Object.keys(gitStatus.files).length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Create pull request</TooltipContent>
|
||||
<TooltipContent>
|
||||
{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'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" variant="outline" className="rounded-none border-0 border-l px-2 cursor-pointer">
|
||||
<IconChevronDown className="h-4 w-4" />
|
||||
<Button size="sm" variant="outline" className="rounded-none border-0 border-l px-2 cursor-pointer" disabled={isGitLoading}>
|
||||
{isGitLoading ? (
|
||||
<IconLoader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<IconChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
// TODO: Implement pull
|
||||
console.log('Pull from remote');
|
||||
}}
|
||||
className="cursor-pointer gap-3"
|
||||
onClick={handleOpenPRDialog}
|
||||
disabled={isGitLoading || !activeSessionId}
|
||||
>
|
||||
<IconArrowUp className="h-4 w-4 rotate-180" />
|
||||
Pull
|
||||
<IconGitPullRequest className="h-4 w-4 text-cyan-500" />
|
||||
<span className="flex-1">Create PR</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer gap-3"
|
||||
onClick={handlePull}
|
||||
disabled={isGitLoading || !activeSessionId}
|
||||
>
|
||||
<IconCloudDownload className="h-4 w-4 text-blue-500" />
|
||||
<span className="flex-1">Pull</span>
|
||||
{(gitStatus?.behind ?? 0) > 0 && (
|
||||
<span className="rounded-full bg-blue-500/10 px-2 py-0.5 text-xs font-medium text-blue-600 dark:text-blue-400">
|
||||
↓{gitStatus?.behind}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
// TODO: Implement rebase
|
||||
console.log('Rebase');
|
||||
}}
|
||||
className="cursor-pointer gap-3"
|
||||
onClick={handlePush}
|
||||
disabled={isGitLoading || !activeSessionId}
|
||||
>
|
||||
<IconGitBranch className="h-4 w-4" />
|
||||
Rebase
|
||||
<IconCloudUpload className="h-4 w-4 text-green-500" />
|
||||
<span className="flex-1">Push</span>
|
||||
{(gitStatus?.ahead ?? 0) > 0 && (
|
||||
<span className="rounded-full bg-green-500/10 px-2 py-0.5 text-xs font-medium text-green-600 dark:text-green-400">
|
||||
↑{gitStatus?.ahead}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer gap-3"
|
||||
onClick={handleRebase}
|
||||
disabled={isGitLoading || !activeSessionId}
|
||||
>
|
||||
<IconGitCherryPick className="h-4 w-4 text-orange-500" />
|
||||
<span className="flex-1">Rebase</span>
|
||||
<span className="text-xs text-muted-foreground">onto {baseBranch || 'origin/main'}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
// TODO: Implement push
|
||||
console.log('Push to remote');
|
||||
}}
|
||||
className="cursor-pointer gap-3"
|
||||
onClick={handleMerge}
|
||||
disabled={isGitLoading || !activeSessionId}
|
||||
>
|
||||
<IconArrowUp className="h-4 w-4" />
|
||||
Push
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
// TODO: Implement merge
|
||||
console.log('Merge');
|
||||
}}
|
||||
>
|
||||
<IconGitMerge className="h-4 w-4" />
|
||||
Merge
|
||||
<IconGitMerge className="h-4 w-4 text-purple-500" />
|
||||
<span className="flex-1">Merge</span>
|
||||
<span className="text-xs text-muted-foreground">from {baseBranch || 'origin/main'}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -343,6 +501,149 @@ const TaskTopBar = memo(function TaskTopBar({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Commit Dialog */}
|
||||
<Dialog open={commitDialogOpen} onOpenChange={setCommitDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<IconGitCommit className="h-5 w-5 text-amber-500" />
|
||||
Commit Changes
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{gitStatus?.files && Object.keys(gitStatus.files).length > 0 ? (
|
||||
<span>
|
||||
<span className="font-medium text-foreground">{Object.keys(gitStatus.files).length}</span> file{Object.keys(gitStatus.files).length !== 1 ? 's' : ''} changed
|
||||
{(totalAdditions > 0 || totalDeletions > 0) && (
|
||||
<span className="ml-2">
|
||||
(<span className="text-green-600">+{totalAdditions}</span>
|
||||
{' / '}
|
||||
<span className="text-red-600">-{totalDeletions}</span>)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span>No changes to commit</span>
|
||||
)}
|
||||
</div>
|
||||
<Textarea
|
||||
placeholder="Enter commit message..."
|
||||
value={commitMessage}
|
||||
onChange={(e) => setCommitMessage(e.target.value)}
|
||||
rows={4}
|
||||
className="resize-none"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="stage-all"
|
||||
checked={stageAll}
|
||||
onCheckedChange={(checked) => setStageAll(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="stage-all" className="text-sm text-muted-foreground cursor-pointer">
|
||||
Stage all changes before committing
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
onClick={handleCommit}
|
||||
disabled={!commitMessage.trim() || isGitLoading}
|
||||
>
|
||||
{isGitLoading ? (
|
||||
<>
|
||||
<IconLoader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Committing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconCheck className="h-4 w-4 mr-2" />
|
||||
Commit
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Create PR Dialog */}
|
||||
<Dialog open={prDialogOpen} onOpenChange={setPrDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<IconGitPullRequest className="h-5 w-5 text-cyan-500" />
|
||||
Create Pull Request
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{baseBranch ? (
|
||||
<span>
|
||||
Creating PR from <span className="font-medium text-foreground">{displayBranch}</span> → <span className="font-medium text-foreground">{baseBranch}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
Creating PR from <span className="font-medium text-foreground">{displayBranch}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pr-title" className="text-sm">Title</Label>
|
||||
<input
|
||||
id="pr-title"
|
||||
type="text"
|
||||
placeholder="Pull request title..."
|
||||
value={prTitle}
|
||||
onChange={(e) => setPrTitle(e.target.value)}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pr-body" className="text-sm">Description</Label>
|
||||
<Textarea
|
||||
id="pr-body"
|
||||
placeholder="Describe your changes..."
|
||||
value={prBody}
|
||||
onChange={(e) => setPrBody(e.target.value)}
|
||||
rows={6}
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
onClick={handleCreatePR}
|
||||
disabled={!prTitle.trim() || isGitLoading}
|
||||
className="bg-cyan-600 hover:bg-cyan-700 text-white"
|
||||
>
|
||||
{isGitLoading ? (
|
||||
<>
|
||||
<IconLoader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconGitPullRequest className="h-4 w-4 mr-2" />
|
||||
Create PR
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</header>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useCallback, useContext, useMemo, useState } from 'react';
|
||||
import { IconCheck, IconX, IconInfoCircle } from '@tabler/icons-react';
|
||||
import { cn, generateUUID } from '@/lib/utils';
|
||||
|
||||
type ToastVariant = 'default' | 'error';
|
||||
type ToastVariant = 'default' | 'success' | 'error';
|
||||
|
||||
type Toast = {
|
||||
id: string;
|
||||
@@ -20,6 +21,24 @@ type ToastContextValue = {
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
const variantStyles: Record<ToastVariant, { container: string; icon: string; IconComponent: typeof IconCheck }> = {
|
||||
default: {
|
||||
container: 'border-border/60 bg-background',
|
||||
icon: 'text-muted-foreground',
|
||||
IconComponent: IconInfoCircle,
|
||||
},
|
||||
success: {
|
||||
container: 'border-green-500/30 bg-green-500/10 dark:bg-green-500/5',
|
||||
icon: 'text-green-600 dark:text-green-400',
|
||||
IconComponent: IconCheck,
|
||||
},
|
||||
error: {
|
||||
container: 'border-red-500/30 bg-red-500/10 dark:bg-red-500/5',
|
||||
icon: 'text-red-600 dark:text-red-400',
|
||||
IconComponent: IconX,
|
||||
},
|
||||
};
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
@@ -36,8 +55,8 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
description: input.description,
|
||||
variant: input.variant ?? 'default',
|
||||
};
|
||||
setToasts((prev) => [nextToast, ...prev]);
|
||||
const duration = input.duration ?? 3000;
|
||||
setToasts((prev) => [...prev, nextToast]);
|
||||
const duration = input.duration ?? 4000;
|
||||
window.setTimeout(() => removeToast(id), duration);
|
||||
},
|
||||
[removeToast]
|
||||
@@ -48,21 +67,34 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<div className="fixed right-4 top-4 z-50 flex w-[320px] flex-col gap-2">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={cn(
|
||||
'rounded-md border border-border/60 bg-background px-3 py-2 shadow-md',
|
||||
toast.variant === 'error' && 'border-destructive/50 bg-destructive/10'
|
||||
)}
|
||||
>
|
||||
{toast.title && <div className="text-sm font-medium">{toast.title}</div>}
|
||||
{toast.description && (
|
||||
<div className="text-xs text-muted-foreground">{toast.description}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="fixed bottom-4 right-4 z-50 flex w-[360px] flex-col-reverse gap-2">
|
||||
{toasts.map((t) => {
|
||||
const variant = t.variant ?? 'default';
|
||||
const styles = variantStyles[variant];
|
||||
const Icon = styles.IconComponent;
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-lg border px-4 py-3 shadow-lg backdrop-blur-sm',
|
||||
'animate-in slide-in-from-right-full duration-300',
|
||||
styles.container
|
||||
)}
|
||||
>
|
||||
<div className={cn('mt-0.5 flex-shrink-0', styles.icon)}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
{t.title && (
|
||||
<div className="text-sm font-semibold leading-tight">{t.title}</div>
|
||||
)}
|
||||
{t.description && (
|
||||
<div className="text-xs leading-relaxed text-muted-foreground">{t.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { getWebSocketClient } from '@/lib/ws/connection';
|
||||
|
||||
// GitOperationResult matches the backend response
|
||||
export interface GitOperationResult {
|
||||
success: boolean;
|
||||
operation: string;
|
||||
output: string;
|
||||
error?: string;
|
||||
conflict_files?: string[];
|
||||
}
|
||||
|
||||
// PRCreateResult matches the backend PR creation response
|
||||
export interface PRCreateResult {
|
||||
success: boolean;
|
||||
pr_url?: string;
|
||||
output?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface UseGitOperationsReturn {
|
||||
// Operation methods
|
||||
pull: (rebase?: boolean) => Promise<GitOperationResult>;
|
||||
push: (options?: { force?: boolean; setUpstream?: boolean }) => Promise<GitOperationResult>;
|
||||
rebase: (baseBranch: string) => Promise<GitOperationResult>;
|
||||
merge: (baseBranch: string) => Promise<GitOperationResult>;
|
||||
abort: (operation: 'merge' | 'rebase') => Promise<GitOperationResult>;
|
||||
commit: (message: string, stageAll?: boolean) => Promise<GitOperationResult>;
|
||||
stage: (paths?: string[]) => Promise<GitOperationResult>;
|
||||
createPR: (title: string, body: string, baseBranch?: string) => Promise<PRCreateResult>;
|
||||
|
||||
// State
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
lastResult: GitOperationResult | null;
|
||||
}
|
||||
|
||||
export function useGitOperations(sessionId: string | null): UseGitOperationsReturn {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastResult, setLastResult] = useState<GitOperationResult | null>(null);
|
||||
|
||||
const executeOperation = useCallback(async <T extends GitOperationResult>(
|
||||
action: string,
|
||||
payload: Record<string, unknown>
|
||||
): Promise<T> => {
|
||||
if (!sessionId) {
|
||||
throw new Error('No session ID provided');
|
||||
}
|
||||
|
||||
const client = getWebSocketClient();
|
||||
if (!client) {
|
||||
throw new Error('WebSocket not connected');
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await client.request<T>(action, {
|
||||
session_id: sessionId,
|
||||
...payload
|
||||
}, 60000); // 60s timeout for git operations
|
||||
|
||||
setLastResult(result);
|
||||
if (!result.success && result.error) {
|
||||
setError(result.error);
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : 'Operation failed';
|
||||
setError(errorMessage);
|
||||
throw e;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
const pull = useCallback(async (rebase = false) => {
|
||||
return executeOperation<GitOperationResult>('worktree.pull', { rebase });
|
||||
}, [executeOperation]);
|
||||
|
||||
const push = useCallback(async (options?: { force?: boolean; setUpstream?: boolean }) => {
|
||||
return executeOperation<GitOperationResult>('worktree.push', {
|
||||
force: options?.force ?? false,
|
||||
set_upstream: options?.setUpstream ?? false,
|
||||
});
|
||||
}, [executeOperation]);
|
||||
|
||||
const rebase = useCallback(async (baseBranch: string) => {
|
||||
return executeOperation<GitOperationResult>('worktree.rebase', { base_branch: baseBranch });
|
||||
}, [executeOperation]);
|
||||
|
||||
const merge = useCallback(async (baseBranch: string) => {
|
||||
return executeOperation<GitOperationResult>('worktree.merge', { base_branch: baseBranch });
|
||||
}, [executeOperation]);
|
||||
|
||||
const abort = useCallback(async (operation: 'merge' | 'rebase') => {
|
||||
return executeOperation<GitOperationResult>('worktree.abort', { operation });
|
||||
}, [executeOperation]);
|
||||
|
||||
const commit = useCallback(async (message: string, stageAll = true) => {
|
||||
return executeOperation<GitOperationResult>('worktree.commit', { message, stage_all: stageAll });
|
||||
}, [executeOperation]);
|
||||
|
||||
const stage = useCallback(async (paths?: string[]) => {
|
||||
return executeOperation<GitOperationResult>('worktree.stage', { paths: paths ?? [] });
|
||||
}, [executeOperation]);
|
||||
|
||||
const createPR = useCallback(async (title: string, body: string, baseBranch?: string): Promise<PRCreateResult> => {
|
||||
if (!sessionId) {
|
||||
throw new Error('No session ID provided');
|
||||
}
|
||||
|
||||
const client = getWebSocketClient();
|
||||
if (!client) {
|
||||
throw new Error('WebSocket not connected');
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await client.request<PRCreateResult>('worktree.create_pr', {
|
||||
session_id: sessionId,
|
||||
title,
|
||||
body,
|
||||
base_branch: baseBranch ?? '',
|
||||
}, 120000); // 2 min timeout for PR creation (gh cli can be slow)
|
||||
|
||||
if (!result.success && result.error) {
|
||||
setError(result.error);
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : 'Operation failed';
|
||||
setError(errorMessage);
|
||||
throw e;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
return {
|
||||
pull,
|
||||
push,
|
||||
rebase,
|
||||
merge,
|
||||
abort,
|
||||
commit,
|
||||
stage,
|
||||
createPR,
|
||||
isLoading,
|
||||
error,
|
||||
lastResult,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -164,6 +164,7 @@ export type ShellState = {
|
||||
export type FileInfo = {
|
||||
path: string;
|
||||
status: 'modified' | 'added' | 'deleted' | 'untracked' | 'renamed';
|
||||
staged: boolean;
|
||||
additions?: number;
|
||||
deletions?: number;
|
||||
old_path?: string;
|
||||
|
||||
@@ -174,6 +174,7 @@ export type TaskSessionAgentctlPayload = {
|
||||
export type FileInfo = {
|
||||
path: string;
|
||||
status: 'modified' | 'added' | 'deleted' | 'untracked' | 'renamed';
|
||||
staged: boolean;
|
||||
additions?: number;
|
||||
deletions?: number;
|
||||
old_path?: string;
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
# Git Operations for Worktrees
|
||||
|
||||
This document describes the architecture for Git operations (Pull, Push, Rebase, Merge) on worktrees in Kandev.
|
||||
|
||||
## Overview
|
||||
|
||||
Git operations are executed **in agentctl**, not in the backend. This ensures consistent behavior whether running locally or in Docker/remote environments. The backend acts as a proxy, forwarding requests from the frontend WebSocket to agentctl's HTTP API.
|
||||
|
||||
## Supported Operations
|
||||
|
||||
| Operation | Direction | Description |
|
||||
|-----------|-----------|-------------|
|
||||
| **Pull** | Remote → Worktree | Fetch and merge changes from remote for the worktree's branch |
|
||||
| **Push** | Worktree → Remote | Push local commits to remote |
|
||||
| **Rebase** | Base → Worktree | Rebase worktree branch onto base branch (rewrites history) |
|
||||
| **Merge** | Base → Worktree | Merge base branch into worktree branch (creates merge commit) |
|
||||
| **Abort** | — | Abort an in-progress merge or rebase |
|
||||
|
||||
## Architecture
|
||||
|
||||
### Request Flow
|
||||
|
||||
```
|
||||
Frontend UI (Git Actions Dropdown)
|
||||
│
|
||||
▼
|
||||
WebSocket Client
|
||||
│ worktree.pull / worktree.push / worktree.rebase / worktree.merge
|
||||
▼
|
||||
Backend WebSocket Gateway
|
||||
│
|
||||
▼
|
||||
Git Handlers (internal/agent/handlers/git_handlers.go)
|
||||
│ GetExecutionBySessionID(sessionID)
|
||||
▼
|
||||
Lifecycle Manager → AgentExecution → agentctl.Client
|
||||
│
|
||||
│ HTTP POST /api/v1/git/<operation>
|
||||
▼
|
||||
agentctl HTTP API (server/api/git.go)
|
||||
│
|
||||
▼
|
||||
Git Command Execution (server/process/git.go)
|
||||
│ exec.Command("git", ...) in worktree directory
|
||||
▼
|
||||
HTTP Response → Backend Handler → WebSocket Response → Frontend
|
||||
```
|
||||
|
||||
### Notification Flow (Git Status Updates)
|
||||
|
||||
After any git operation, the workspace state changes. Updates flow through the event bus:
|
||||
|
||||
```
|
||||
agentctl WorkspaceTracker (detects git status change)
|
||||
│ workspace stream: git_status message
|
||||
▼
|
||||
Backend StreamManager (OnGitStatus callback)
|
||||
│
|
||||
▼
|
||||
EventPublisher.PublishGitStatus()
|
||||
│
|
||||
▼
|
||||
Event Bus
|
||||
│
|
||||
▼
|
||||
TaskNotificationBridge
|
||||
│
|
||||
▼
|
||||
Hub.BroadcastToSession(sessionID)
|
||||
│
|
||||
▼
|
||||
Frontend WebSocket → Zustand Store update
|
||||
```
|
||||
|
||||
## agentctl API
|
||||
|
||||
### Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/api/v1/git/pull` | Pull from remote |
|
||||
| POST | `/api/v1/git/push` | Push to remote |
|
||||
| POST | `/api/v1/git/rebase` | Rebase onto base branch |
|
||||
| POST | `/api/v1/git/merge` | Merge base branch in |
|
||||
| POST | `/api/v1/git/abort` | Abort merge/rebase |
|
||||
|
||||
### Request/Response Format
|
||||
|
||||
**Pull Request:**
|
||||
```json
|
||||
{ "rebase": false }
|
||||
```
|
||||
|
||||
**Push Request:**
|
||||
```json
|
||||
{ "force": false, "set_upstream": true }
|
||||
```
|
||||
|
||||
**Rebase/Merge Request:**
|
||||
```json
|
||||
{ "base_branch": "main" }
|
||||
```
|
||||
|
||||
**Abort Request:**
|
||||
```json
|
||||
{ "operation": "merge" }
|
||||
```
|
||||
|
||||
**Response (all operations):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"operation": "pull",
|
||||
"output": "Already up to date.",
|
||||
"error": null,
|
||||
"conflict_files": []
|
||||
}
|
||||
```
|
||||
|
||||
## Locking
|
||||
|
||||
A per-instance mutex in agentctl prevents concurrent git operations on the same worktree. If an operation is already running, new requests return HTTP 409 Conflict.
|
||||
|
||||
## Conflict Handling
|
||||
|
||||
- **Rebase conflicts**: Automatically aborted (`git rebase --abort`), conflict files returned
|
||||
- **Merge conflicts**: Left in place for user/agent resolution, conflict files returned
|
||||
- **Abort endpoint**: Allows manual abort of conflicted merge/rebase
|
||||
|
||||
## Frontend Integration
|
||||
|
||||
### WebSocket Actions
|
||||
|
||||
- `worktree.pull` - Pull from remote
|
||||
- `worktree.push` - Push to remote
|
||||
- `worktree.rebase` - Rebase onto base
|
||||
- `worktree.merge` - Merge base branch
|
||||
- `worktree.abort` - Abort operation
|
||||
|
||||
### Hook Usage
|
||||
|
||||
```typescript
|
||||
const { pull, push, rebase, merge, isLoading, error } = useGitOperations(sessionId);
|
||||
|
||||
// In component
|
||||
<Button onClick={() => pull()} disabled={isLoading}>Pull</Button>
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
### agentctl
|
||||
- `server/api/git.go` - HTTP handlers
|
||||
- `server/process/git.go` - Git command execution
|
||||
- `client/git.go` - Client methods
|
||||
|
||||
### Backend
|
||||
- `internal/agent/handlers/git_handlers.go` - WebSocket handlers
|
||||
- `pkg/websocket/actions.go` - Action constants
|
||||
|
||||
### Frontend
|
||||
- `lib/types/backend.ts` - TypeScript types
|
||||
- `lib/ws/actions/git.ts` - WebSocket action functions
|
||||
- `lib/hooks/useGitOperations.ts` - React hook
|
||||
|
||||
Reference in New Issue
Block a user