df3c9142ab
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
1635 lines
55 KiB
Go
1635 lines
55 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/kandev/kandev/internal/agentctl/server/process"
|
|
"github.com/kandev/kandev/internal/common/subproc"
|
|
"go.uber.org/zap"
|
|
"golang.org/x/sync/errgroup"
|
|
)
|
|
|
|
// safeBranchRefPattern mirrors the one in workspace_git_status.go so the
|
|
// HTTP handlers can perform an inline allowlist check at the request
|
|
// boundary without the extra hop through a helper that would obscure the
|
|
// barrier from CodeQL's `go/command-injection` taint tracker.
|
|
var safeBranchRefPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._/-]*$`)
|
|
|
|
// queryParamTrue is the string value used to indicate a true boolean in query parameters.
|
|
const queryParamTrue = "true"
|
|
|
|
// sortCommitsByCommittedAtDesc sorts commits newest-first using committed_at.
|
|
// Commits with unparseable timestamps preserve their original relative order
|
|
// (sort.SliceStable). Used when merging logs from multiple repos so the
|
|
// combined timeline reads chronologically.
|
|
func sortCommitsByCommittedAtDesc(commits []*process.GitCommitInfo) {
|
|
sort.SliceStable(commits, func(i, j int) bool {
|
|
ti, errI := time.Parse(time.RFC3339, commits[i].CommittedAt)
|
|
tj, errJ := time.Parse(time.RFC3339, commits[j].CommittedAt)
|
|
if errI != nil || errJ != nil {
|
|
return false
|
|
}
|
|
return ti.After(tj)
|
|
})
|
|
}
|
|
|
|
// Repo selects a repository sub-directory of the workspace for multi-repo
|
|
// task roots. Optional. Empty = workspace root (single-repo behavior).
|
|
// All git request structs below embed this field via the standard `repo` JSON
|
|
// key (or `repo` form key for GET endpoints).
|
|
|
|
// GitPullRequest for POST /api/v1/git/pull
|
|
type GitPullRequest struct {
|
|
Rebase bool `json:"rebase"`
|
|
Repo string `json:"repo,omitempty"`
|
|
}
|
|
|
|
// GitPushRequest for POST /api/v1/git/push
|
|
//
|
|
// Remote and ExpectedBranch are optional. Remote is a configured remote name or
|
|
// a remote URL that must resolve to one; ExpectedBranch is the branch the
|
|
// caller believes it is publishing. Both are passed through untouched: this
|
|
// surface performs no destination logic of its own.
|
|
type GitPushRequest struct {
|
|
Force bool `json:"force"`
|
|
SetUpstream bool `json:"set_upstream"`
|
|
Repo string `json:"repo,omitempty"`
|
|
Remote string `json:"remote,omitempty"`
|
|
ExpectedBranch string `json:"expected_branch,omitempty"`
|
|
}
|
|
|
|
// GitPushPreflightRequest for POST /api/v1/git/push-preflight.
|
|
type GitPushPreflightRequest struct {
|
|
Repo string `json:"repo,omitempty"`
|
|
Remote string `json:"remote,omitempty"`
|
|
ExpectedBranch string `json:"expected_branch,omitempty"`
|
|
}
|
|
|
|
// GitContributionRequest is shared by the managed contribution replacement
|
|
// and provider-adoption endpoints.
|
|
type GitContributionRequest struct {
|
|
ExpectedRemoteHead string `json:"expected_remote_head"`
|
|
Repo string `json:"repo,omitempty"`
|
|
}
|
|
|
|
// GitRebaseRequest for POST /api/v1/git/rebase
|
|
type GitRebaseRequest struct {
|
|
BaseBranch string `json:"base_branch"`
|
|
Repo string `json:"repo,omitempty"`
|
|
}
|
|
|
|
// GitMergeRequest for POST /api/v1/git/merge
|
|
type GitMergeRequest struct {
|
|
BaseBranch string `json:"base_branch"`
|
|
Repo string `json:"repo,omitempty"`
|
|
}
|
|
|
|
// GitAbortRequest for POST /api/v1/git/abort
|
|
type GitAbortRequest struct {
|
|
Operation string `json:"operation"` // "merge" or "rebase"
|
|
Repo string `json:"repo,omitempty"`
|
|
}
|
|
|
|
// GitCommitRequest for POST /api/v1/git/commit
|
|
type GitCommitRequest struct {
|
|
Message string `json:"message"`
|
|
StageAll bool `json:"stage_all"`
|
|
Amend bool `json:"amend"`
|
|
Repo string `json:"repo,omitempty"`
|
|
}
|
|
|
|
// GitRenameBranchRequest for POST /api/v1/git/rename-branch
|
|
type GitRenameBranchRequest struct {
|
|
NewName string `json:"new_name"`
|
|
Repo string `json:"repo,omitempty"`
|
|
}
|
|
|
|
// GitStageRequest for POST /api/v1/git/stage
|
|
type GitStageRequest struct {
|
|
Paths []string `json:"paths"` // Empty = stage all
|
|
Repo string `json:"repo,omitempty"`
|
|
}
|
|
|
|
// GitUnstageRequest for POST /api/v1/git/unstage
|
|
type GitUnstageRequest struct {
|
|
Paths []string `json:"paths"` // Empty = unstage all
|
|
Repo string `json:"repo,omitempty"`
|
|
}
|
|
|
|
// GitDiscardRequest for POST /api/v1/git/discard
|
|
type GitDiscardRequest struct {
|
|
Paths []string `json:"paths"` // Required - files to discard
|
|
Repo string `json:"repo,omitempty"`
|
|
}
|
|
|
|
// GitShowCommitRequest for GET /api/v1/git/commit/:sha
|
|
type GitShowCommitRequest struct {
|
|
CommitSHA string `uri:"sha" binding:"required"`
|
|
}
|
|
|
|
// GitRevertCommitRequest for POST /api/v1/git/revert-commit
|
|
type GitRevertCommitRequest struct {
|
|
CommitSHA string `json:"commit_sha"`
|
|
Repo string `json:"repo,omitempty"`
|
|
}
|
|
|
|
// GitCreatePRRequest for POST /api/v1/git/create-pr
|
|
type GitCreatePRRequest struct {
|
|
Title string `json:"title"`
|
|
Body string `json:"body"`
|
|
BaseBranch string `json:"base_branch"`
|
|
Draft bool `json:"draft"`
|
|
Repo string `json:"repo,omitempty"`
|
|
}
|
|
|
|
// GitResetRequest for POST /api/v1/git/reset
|
|
type GitResetRequest struct {
|
|
CommitSHA string `json:"commit_sha"`
|
|
Mode string `json:"mode"` // "soft", "mixed", or "hard"
|
|
Repo string `json:"repo,omitempty"`
|
|
}
|
|
|
|
// GitLogRequest for GET /api/v1/git/log
|
|
type GitLogRequest struct {
|
|
Since string `form:"since"` // Base commit SHA (exclusive)
|
|
TargetBranch string `form:"target_branch"` // Target branch for merge-base calculation (e.g., "origin/main")
|
|
Limit int `form:"limit"` // Max commits to return
|
|
Repo string `form:"repo"`
|
|
}
|
|
|
|
// GitCumulativeDiffRequest for GET /api/v1/git/cumulative-diff
|
|
type GitCumulativeDiffRequest struct {
|
|
Base string `form:"base" binding:"required"` // Base commit SHA (used as fallback when target_branch is empty or merge-base fails)
|
|
TargetBranch string `form:"target_branch"` // When set, recompute base via merge-base HEAD <origin/target_branch> for live divergence
|
|
Repo string `form:"repo"`
|
|
}
|
|
|
|
// gitOpForRepo resolves the optional repo subpath to a per-repo GitOperator.
|
|
// On invalid subpath it writes a 400 response and returns nil so callers can
|
|
// `return` immediately. Empty subpath returns the workspace-root operator.
|
|
func (s *Server) gitOpForRepo(c *gin.Context, operation, subpath string) *process.GitOperator {
|
|
op, err := s.procMgr.GitOperatorFor(subpath)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, process.GitOperationResult{
|
|
Success: false,
|
|
Operation: operation,
|
|
Error: err.Error(),
|
|
})
|
|
return nil
|
|
}
|
|
return op
|
|
}
|
|
|
|
// 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.gitOpForRepo(c, "pull", req.Repo)
|
|
if gitOp == nil {
|
|
return
|
|
}
|
|
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.gitOpForRepo(c, "push", req.Repo)
|
|
if gitOp == nil {
|
|
return
|
|
}
|
|
result, err := gitOp.Push(c.Request.Context(), process.PushOptions{
|
|
Force: req.Force,
|
|
SetUpstream: req.SetUpstream,
|
|
Remote: req.Remote,
|
|
ExpectedBranch: req.ExpectedBranch,
|
|
})
|
|
if err != nil {
|
|
s.handleGitError(c, "push", err)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
func (s *Server) handleGitPushPreflight(c *gin.Context) {
|
|
var req GitPushPreflightRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, process.GitOperationResult{
|
|
Success: false, Operation: "push_preflight", Error: "invalid request: " + err.Error(),
|
|
})
|
|
return
|
|
}
|
|
gitOp := s.gitOpForRepo(c, "push preflight", req.Repo)
|
|
if gitOp == nil {
|
|
return
|
|
}
|
|
result, err := gitOp.PushPreflight(c.Request.Context(), process.PushOptions{
|
|
Remote: req.Remote,
|
|
ExpectedBranch: req.ExpectedBranch,
|
|
})
|
|
if err != nil {
|
|
s.handleGitError(c, "push preflight", err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
func (s *Server) handleGitReplaceContribution(c *gin.Context) {
|
|
s.handleGitContribution(c, "replace_remote_contribution", func(gitOp *process.GitOperator, expected string) (*process.GitOperationResult, error) {
|
|
return gitOp.ReplaceRemoteContribution(c.Request.Context(), expected)
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleGitUseContribution(c *gin.Context) {
|
|
s.handleGitContribution(c, "use_remote_contribution", func(gitOp *process.GitOperator, expected string) (*process.GitOperationResult, error) {
|
|
return gitOp.UseRemoteContribution(c.Request.Context(), expected)
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleGitContribution(c *gin.Context, operation string, action func(*process.GitOperator, string) (*process.GitOperationResult, error)) {
|
|
var req GitContributionRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, process.GitOperationResult{
|
|
Success: false, Operation: operation, Error: "invalid request: " + err.Error(),
|
|
})
|
|
return
|
|
}
|
|
if req.ExpectedRemoteHead == "" {
|
|
c.JSON(http.StatusBadRequest, process.GitOperationResult{
|
|
Success: false, Operation: operation, Error: "expected_remote_head is required",
|
|
})
|
|
return
|
|
}
|
|
|
|
gitOp := s.gitOpForRepo(c, operation, req.Repo)
|
|
if gitOp == nil {
|
|
return
|
|
}
|
|
result, err := action(gitOp, req.ExpectedRemoteHead)
|
|
if err != nil {
|
|
s.handleGitError(c, operation, 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.gitOpForRepo(c, "rebase", req.Repo)
|
|
if gitOp == nil {
|
|
return
|
|
}
|
|
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.gitOpForRepo(c, "merge", req.Repo)
|
|
if gitOp == nil {
|
|
return
|
|
}
|
|
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.gitOpForRepo(c, "abort", req.Repo)
|
|
if gitOp == nil {
|
|
return
|
|
}
|
|
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.gitOpForRepo(c, "commit", req.Repo)
|
|
if gitOp == nil {
|
|
return
|
|
}
|
|
result, err := gitOp.Commit(c.Request.Context(), req.Message, req.StageAll, req.Amend)
|
|
if err != nil {
|
|
s.handleGitError(c, "commit", err)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
// handleGitRenameBranch handles POST /api/v1/git/rename-branch
|
|
func (s *Server) handleGitRenameBranch(c *gin.Context) {
|
|
var req GitRenameBranchRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, process.GitOperationResult{
|
|
Success: false,
|
|
Operation: "rename_branch",
|
|
Error: "invalid request: " + err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
if req.NewName == "" {
|
|
c.JSON(http.StatusBadRequest, process.GitOperationResult{
|
|
Success: false,
|
|
Operation: "rename_branch",
|
|
Error: "new_name is required",
|
|
})
|
|
return
|
|
}
|
|
|
|
gitOp := s.gitOpForRepo(c, "rename_branch", req.Repo)
|
|
if gitOp == nil {
|
|
return
|
|
}
|
|
result, err := gitOp.RenameBranch(c.Request.Context(), req.NewName)
|
|
if err != nil {
|
|
s.handleGitError(c, "rename_branch", 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.gitOpForRepo(c, "stage", req.Repo)
|
|
if gitOp == nil {
|
|
return
|
|
}
|
|
result, err := gitOp.Stage(c.Request.Context(), req.Paths)
|
|
if err != nil {
|
|
s.handleGitError(c, "stage", err)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
// handleGitUnstage handles POST /api/v1/git/unstage
|
|
func (s *Server) handleGitUnstage(c *gin.Context) {
|
|
var req GitUnstageRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, process.GitOperationResult{
|
|
Success: false,
|
|
Operation: "unstage",
|
|
Error: "invalid request: " + err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
gitOp := s.gitOpForRepo(c, "unstage", req.Repo)
|
|
if gitOp == nil {
|
|
return
|
|
}
|
|
result, err := gitOp.Unstage(c.Request.Context(), req.Paths)
|
|
if err != nil {
|
|
s.handleGitError(c, "unstage", err)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
// handleGitDiscard handles POST /api/v1/git/discard
|
|
func (s *Server) handleGitDiscard(c *gin.Context) {
|
|
var req GitDiscardRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, process.GitOperationResult{
|
|
Success: false,
|
|
Operation: "discard",
|
|
Error: "invalid request: " + err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
if len(req.Paths) == 0 {
|
|
c.JSON(http.StatusBadRequest, process.GitOperationResult{
|
|
Success: false,
|
|
Operation: "discard",
|
|
Error: "paths are required",
|
|
})
|
|
return
|
|
}
|
|
|
|
gitOp := s.gitOpForRepo(c, "discard", req.Repo)
|
|
if gitOp == nil {
|
|
return
|
|
}
|
|
result, err := gitOp.Discard(c.Request.Context(), req.Paths)
|
|
if err != nil {
|
|
s.handleGitError(c, "discard", err)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
// handleGitCreatePR handles POST /api/v1/git/create-pr
|
|
func (s *Server) handleGitCreatePR(c *gin.Context) {
|
|
var req GitCreatePRRequest
|
|
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, gitOpErr := s.procMgr.GitOperatorFor(req.Repo)
|
|
if gitOpErr != nil {
|
|
c.JSON(http.StatusBadRequest, process.PRCreateResult{
|
|
Success: false,
|
|
Error: gitOpErr.Error(),
|
|
})
|
|
return
|
|
}
|
|
result, err := gitOp.CreatePR(c.Request.Context(), req.Title, req.Body, req.BaseBranch, req.Draft)
|
|
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)
|
|
}
|
|
|
|
// handleGitRevertCommit handles POST /api/v1/git/revert-commit
|
|
func (s *Server) handleGitRevertCommit(c *gin.Context) {
|
|
var req GitRevertCommitRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, process.GitOperationResult{
|
|
Success: false,
|
|
Operation: "revert_commit",
|
|
Error: "invalid request: " + err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
if req.CommitSHA == "" {
|
|
c.JSON(http.StatusBadRequest, process.GitOperationResult{
|
|
Success: false,
|
|
Operation: "revert_commit",
|
|
Error: "commit_sha is required",
|
|
})
|
|
return
|
|
}
|
|
|
|
gitOp := s.gitOpForRepo(c, "revert_commit", req.Repo)
|
|
if gitOp == nil {
|
|
return
|
|
}
|
|
result, err := gitOp.RevertCommit(c.Request.Context(), req.CommitSHA)
|
|
if err != nil {
|
|
s.handleGitError(c, "revert_commit", err)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
// handleGitShowCommit handles GET /api/v1/git/commit/:sha
|
|
func (s *Server) handleGitShowCommit(c *gin.Context) {
|
|
var req GitShowCommitRequest
|
|
if err := c.ShouldBindUri(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, process.CommitDiffResult{
|
|
Success: false,
|
|
Error: "invalid request: " + err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
// ShowCommit accepts an optional ?repo= query param.
|
|
gitOp, gitOpErr := s.procMgr.GitOperatorFor(c.Query("repo"))
|
|
if gitOpErr != nil {
|
|
c.JSON(http.StatusBadRequest, process.CommitDiffResult{
|
|
Success: false,
|
|
CommitSHA: req.CommitSHA,
|
|
Error: gitOpErr.Error(),
|
|
})
|
|
return
|
|
}
|
|
result, err := gitOp.ShowCommit(c.Request.Context(), req.CommitSHA)
|
|
if err != nil {
|
|
s.logger.Error("git show commit failed", zap.String("commit_sha", req.CommitSHA), zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, process.CommitDiffResult{
|
|
Success: false,
|
|
CommitSHA: req.CommitSHA,
|
|
Error: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
// handleGitReset handles POST /api/v1/git/reset
|
|
func (s *Server) handleGitReset(c *gin.Context) {
|
|
var req GitResetRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, process.GitOperationResult{
|
|
Success: false,
|
|
Operation: "reset",
|
|
Error: "invalid request: " + err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
if req.CommitSHA == "" {
|
|
c.JSON(http.StatusBadRequest, process.GitOperationResult{
|
|
Success: false,
|
|
Operation: "reset",
|
|
Error: "commit_sha is required",
|
|
})
|
|
return
|
|
}
|
|
|
|
if req.Mode == "" {
|
|
req.Mode = "mixed"
|
|
}
|
|
validModes := map[string]bool{"soft": true, "mixed": true, "hard": true}
|
|
if !validModes[req.Mode] {
|
|
c.JSON(http.StatusBadRequest, process.GitOperationResult{
|
|
Success: false,
|
|
Operation: "reset",
|
|
Error: fmt.Sprintf("invalid reset mode: %s (must be soft, mixed, or hard)", req.Mode),
|
|
})
|
|
return
|
|
}
|
|
|
|
gitOp := s.gitOpForRepo(c, "reset", req.Repo)
|
|
if gitOp == nil {
|
|
return
|
|
}
|
|
result, err := gitOp.Reset(c.Request.Context(), req.CommitSHA, req.Mode)
|
|
if err != nil {
|
|
s.handleGitError(c, "reset", err)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
// handleGitLog handles GET /api/v1/git/log.
|
|
//
|
|
// Multi-repo: when the caller doesn't pin a repo (?repo= empty) and the
|
|
// workspace contains per-repo subdirs, fan out one log per repo and merge
|
|
// the results, stamping RepositoryName on each commit so the frontend can
|
|
// group them. Without this, the call would land on the workspace root which
|
|
// for multi-repo task workspaces isn't a git repo and returns nothing.
|
|
func (s *Server) handleGitLog(c *gin.Context) {
|
|
var req GitLogRequest
|
|
if err := c.ShouldBindQuery(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, process.GitLogResult{
|
|
Success: false,
|
|
Error: "invalid request: " + err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Bound limit to prevent expensive history scans
|
|
const (
|
|
defaultLogLimit = 100
|
|
maxLogLimit = 500
|
|
)
|
|
limit := req.Limit
|
|
if limit <= 0 {
|
|
limit = defaultLogLimit
|
|
} else if limit > maxLogLimit {
|
|
c.JSON(http.StatusBadRequest, process.GitLogResult{
|
|
Success: false,
|
|
Error: fmt.Sprintf("limit must be between 1 and %d", maxLogLimit),
|
|
})
|
|
return
|
|
}
|
|
|
|
if req.Repo == "" {
|
|
if scopes := s.procMgr.RepositoryScopes(); shouldFanOutRepositoryScopes(scopes) {
|
|
s.handleGitLogMultiRepo(c, req, scopes, limit)
|
|
return
|
|
}
|
|
}
|
|
|
|
// c.Request.Context(), not c: *gin.Context satisfies context.Context, so
|
|
// passing c compiles but hands over a context whose Done() is nil unless
|
|
// ContextWithFallback is set — which it is not here. The git subprocesses
|
|
// would then outlive a client disconnect.
|
|
result, err := s.runGitLogForRepo(c.Request.Context(), req, limit, req.Repo)
|
|
if err != nil {
|
|
s.handleGitError(c, "log", err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
// computeMergeBase finds the merge-base between HEAD and the target branch.
|
|
// It tries origin/<target_branch> first (the upstream source of truth) and
|
|
// falls back to <target_branch> if the remote ref isn't present. Local refs
|
|
// can lag arbitrarily far behind upstream on long-lived worktrees; using the
|
|
// remote ref keeps the log/diff range anchored to live divergence.
|
|
func (s *Server) computeMergeBase(
|
|
ctx context.Context,
|
|
gitOp *process.GitOperator,
|
|
targetBranch string,
|
|
) (string, error) {
|
|
if mb, err := gitOp.GetMergeBase(ctx, "HEAD", "origin/"+targetBranch); err == nil {
|
|
return mb, nil
|
|
}
|
|
return gitOp.GetMergeBase(ctx, "HEAD", targetBranch)
|
|
}
|
|
|
|
func (s *Server) comparisonResolutionFor(repo string) (process.ComparisonResolution, error) {
|
|
tracker, err := s.procMgr.GetWorkspaceTrackerFor(repo)
|
|
if err != nil {
|
|
return process.ComparisonResolution{}, err
|
|
}
|
|
return tracker.ComparisonResolution(), nil
|
|
}
|
|
|
|
func comparisonUnavailableLogResult(code string) *process.GitLogResult {
|
|
return &process.GitLogResult{
|
|
Success: false,
|
|
Commits: []*process.GitCommitInfo{},
|
|
Error: "comparison target unavailable",
|
|
ErrorCode: code,
|
|
}
|
|
}
|
|
|
|
func comparisonUnavailableDiffResult(code string) *process.CumulativeDiffResult {
|
|
return &process.CumulativeDiffResult{
|
|
Success: false,
|
|
Files: map[string]interface{}{},
|
|
Error: "comparison target unavailable",
|
|
ErrorCode: code,
|
|
}
|
|
}
|
|
|
|
// runGitLogForRepo runs git log against a single repo subpath. Returns a
|
|
// result-with-error or a non-nil error for transport failures.
|
|
func (s *Server) runGitLogForRepo(
|
|
ctx context.Context,
|
|
req GitLogRequest,
|
|
limit int,
|
|
repo string,
|
|
) (*process.GitLogResult, error) {
|
|
gitOp, gitOpErr := s.procMgr.GitOperatorFor(repo)
|
|
if gitOpErr != nil {
|
|
return nil, gitOpErr
|
|
}
|
|
comparison, comparisonErr := s.comparisonResolutionFor(repo)
|
|
if comparisonErr != nil {
|
|
return nil, comparisonErr
|
|
}
|
|
if comparison.Explicit {
|
|
if comparison.Status != lspStatusReady || comparison.Ref == "" {
|
|
return comparisonUnavailableLogResult(comparison.ErrorCode), nil
|
|
}
|
|
mergeBase, err := gitOp.GetMergeBase(ctx, "HEAD", comparison.Ref)
|
|
if err != nil || mergeBase == "" {
|
|
return comparisonUnavailableLogResult("comparison_merge_base_unavailable"), nil
|
|
}
|
|
return gitOp.GetLog(ctx, mergeBase, limit)
|
|
}
|
|
|
|
baseCommit := req.Since
|
|
// TargetBranch reaches this handler over HTTP and is interpolated into
|
|
// `git` arg lists below. Inline the securityutil.IsValidBranchName
|
|
// allowlist check at the sink call site so CodeQL's taint tracker
|
|
// sees the regex sanitiser barrier in the same function as the
|
|
// subprocess invocation. `origin/<name>` refs are split so the
|
|
// underlying validator (which disallows "/" as the first character)
|
|
// can validate the branch component.
|
|
check, hasOriginPrefix := strings.CutPrefix(req.TargetBranch, "origin/")
|
|
if !hasOriginPrefix {
|
|
check = req.TargetBranch
|
|
}
|
|
if !safeBranchRefPattern.MatchString(check) || strings.Contains(check, "..") || strings.HasSuffix(check, ".lock") {
|
|
req.TargetBranch = ""
|
|
}
|
|
if req.TargetBranch != "" {
|
|
mergeBase, err := s.computeMergeBase(ctx, gitOp, req.TargetBranch)
|
|
if err == nil && mergeBase != "" {
|
|
baseCommit = mergeBase
|
|
} else {
|
|
// merge-base failed (typically unrelated histories) — fall back
|
|
// to the branch tip so GetLog gets a real anchor and runs
|
|
// `git log <tip>..HEAD` instead of dropping into its open-ended
|
|
// "last N commits" path. Without this, picking a base that
|
|
// shares no history with HEAD silently turns the commits panel
|
|
// into the workspace's full HEAD history, mismatching the
|
|
// numstat-driven stats which fall through cleanly to per-file
|
|
// sums in the same scenario.
|
|
if tip, tipErr := gitOp.GetRevParse(ctx, req.TargetBranch); tipErr == nil && tip != "" {
|
|
baseCommit = tip
|
|
} else if err != nil {
|
|
s.logger.Warn("failed to compute merge-base and branch tip, falling back to since",
|
|
zap.String("target_branch", req.TargetBranch),
|
|
zap.String("repo", repo),
|
|
zap.Error(err))
|
|
}
|
|
}
|
|
// Re-anchor a stale stored base (merged/deleted stacked parent, rebase)
|
|
// to the live integration merge-base so the panel counts only the
|
|
// branch's own commits. Only fires when the target has no live upstream
|
|
// ref, so a live non-default target (e.g. origin/develop) keeps its own
|
|
// authoritative merge-base. No-op when the base is already current.
|
|
baseCommit = gitOp.CorrectStaleComparisonBase(
|
|
ctx,
|
|
baseCommit,
|
|
req.TargetBranch,
|
|
)
|
|
}
|
|
|
|
return gitOp.GetLog(ctx, baseCommit, limit)
|
|
}
|
|
|
|
// perRepoLogOutcome captures the outcome of running git log against a single
|
|
// repo subpath. Exactly one of result/err is set; result.Success may still be
|
|
// false if the per-repo command itself reported a failure.
|
|
type perRepoLogOutcome struct {
|
|
subpath string
|
|
result *process.GitLogResult
|
|
err error
|
|
}
|
|
|
|
// mergeGitLogResults combines per-repo log outcomes into a single response
|
|
// suitable for the multi-repo log endpoint. It is extracted from
|
|
// handleGitLogMultiRepo so the merge/error-aggregation logic is unit-testable
|
|
// without spinning up a real Manager.
|
|
//
|
|
// Behavior:
|
|
// - Successful repos contribute their commits (each tagged with RepositoryName).
|
|
// - Failed repos (transport error or result.Success=false) contribute an
|
|
// entry to PerRepoErrors so the frontend can render an "incomplete" warning.
|
|
// - Success=true iff at least one repo succeeded. If every repo failed,
|
|
// Success=false with a summary in Error.
|
|
// - Commits are sorted newest-first across repos and truncated to limit.
|
|
func mergeGitLogResults(outcomes []perRepoLogOutcome, limit int) process.GitLogResult {
|
|
merged := make([]*process.GitCommitInfo, 0)
|
|
perRepoErrors := make([]process.GitLogRepoError, 0)
|
|
for _, o := range outcomes {
|
|
if o.err != nil {
|
|
perRepoErrors = append(perRepoErrors, process.GitLogRepoError{
|
|
RepositoryName: o.subpath,
|
|
Error: o.err.Error(),
|
|
})
|
|
continue
|
|
}
|
|
if o.result == nil || !o.result.Success {
|
|
errMsg := ""
|
|
errorCode := ""
|
|
if o.result != nil {
|
|
errMsg = o.result.Error
|
|
errorCode = o.result.ErrorCode
|
|
}
|
|
perRepoErrors = append(perRepoErrors, process.GitLogRepoError{
|
|
RepositoryName: o.subpath,
|
|
Error: errMsg,
|
|
ErrorCode: errorCode,
|
|
})
|
|
continue
|
|
}
|
|
for _, commit := range o.result.Commits {
|
|
commit.RepositoryName = o.subpath
|
|
merged = append(merged, commit)
|
|
}
|
|
}
|
|
|
|
// Sort newest-first across repos. Falls back to insertion order if either
|
|
// timestamp is unparseable.
|
|
sortCommitsByCommittedAtDesc(merged)
|
|
if limit > 0 && len(merged) > limit {
|
|
merged = merged[:limit]
|
|
}
|
|
|
|
resp := process.GitLogResult{Commits: merged}
|
|
if len(perRepoErrors) > 0 {
|
|
resp.PerRepoErrors = perRepoErrors
|
|
for _, repoErr := range perRepoErrors {
|
|
if repoErr.ErrorCode != "" {
|
|
resp.ErrorCode = repoErr.ErrorCode
|
|
break
|
|
}
|
|
}
|
|
}
|
|
// Success iff at least one repo succeeded. When every repo failed we mark
|
|
// the response failed and put a one-line summary in Error so callers that
|
|
// only check Success/Error keep working without inspecting PerRepoErrors.
|
|
switch {
|
|
case len(outcomes) > 0 && len(perRepoErrors) == len(outcomes):
|
|
resp.Success = false
|
|
resp.Error = fmt.Sprintf("git log failed for all %d repositories", len(outcomes))
|
|
default:
|
|
resp.Success = true
|
|
}
|
|
return resp
|
|
}
|
|
|
|
// handleGitLogMultiRepo fans the request out across every per-repo tracker,
|
|
// stamps RepositoryName on each returned commit, and merges into a single
|
|
// log sorted by committed_at descending. Per-repo limits are applied
|
|
// individually; the merged result is then truncated to the request limit.
|
|
//
|
|
// Per-repo failures are surfaced via PerRepoErrors rather than silently
|
|
// dropped, so the frontend can render an "incomplete" warning instead of
|
|
// pretending the missing repo simply has no history. The merged response
|
|
// stays Success=true as long as at least one repo succeeded; if every repo
|
|
// failed, Success=false with a summary in Error.
|
|
func (s *Server) handleGitLogMultiRepo(
|
|
c *gin.Context,
|
|
req GitLogRequest,
|
|
subpaths []string,
|
|
limit int,
|
|
) {
|
|
// Parallel fan-out, mirroring handleGitStatusMulti. Each repo is an
|
|
// independent git invocation against its own worktree, so running them
|
|
// serially made latency the sum over repositories: on Windows, where a git
|
|
// spawn alone costs ~150ms, an 11-repo task could exceed the gateway's
|
|
// 60s singleflight budget and the caller would see a timeout rather than a
|
|
// slow answer. Writing by index keeps the merge order deterministic.
|
|
ctx := c.Request.Context()
|
|
outcomes := fanOutRepos(ctx, subpaths, func(ctx context.Context, sub string) perRepoLogOutcome {
|
|
return s.collectLogForRepo(ctx, req, limit, sub)
|
|
})
|
|
|
|
c.JSON(http.StatusOK, mergeGitLogResults(outcomes, limit))
|
|
}
|
|
|
|
// collectLogForRepo runs git log for one repository of a multi-repo fan-out.
|
|
// It takes a plain context and never touches the gin context, which is what
|
|
// makes it safe to call from several goroutines at once.
|
|
func (s *Server) collectLogForRepo(
|
|
ctx context.Context,
|
|
req GitLogRequest,
|
|
limit int,
|
|
sub string,
|
|
) perRepoLogOutcome {
|
|
// Multi-repo: the caller-supplied `since` is a SHA that only exists in
|
|
// the primary repo, and `target_branch` is the primary repo's base
|
|
// branch (e.g. "main"). Both can be wrong for sibling repos — running
|
|
// `git log <foreign-sha>..HEAD` in lvc fails outright and the repo's
|
|
// commits silently disappear from the merged response. Compute a
|
|
// per-repo base through the workspace tracker so the task's configured
|
|
// base branch and normal integration-branch fallbacks both work.
|
|
perRepoReq := req
|
|
perRepoReq.Since = ""
|
|
perRepoReq.TargetBranch = ""
|
|
if base := s.resolvePerRepoBase(ctx, sub); base != "" {
|
|
perRepoReq.Since = base
|
|
}
|
|
result, err := s.runGitLogForRepo(ctx, perRepoReq, limit, sub)
|
|
if err != nil {
|
|
s.logger.Warn("git log for repo failed",
|
|
zap.String("repo", sub), zap.Error(err))
|
|
} else if !result.Success {
|
|
s.logger.Warn("git log for repo returned failure",
|
|
zap.String("repo", sub), zap.String("error", result.Error))
|
|
}
|
|
return perRepoLogOutcome{subpath: sub, result: result, err: err}
|
|
}
|
|
|
|
// handleGitCumulativeDiff handles GET /api/v1/git/cumulative-diff.
|
|
//
|
|
// Multi-repo: when the caller doesn't pin a repo (?repo= empty) and the
|
|
// workspace contains per-repo subdirs, fan out one cumulative diff per repo
|
|
// and merge results, prefixing each file key with the repo subpath and
|
|
// stamping repository_name onto each file's payload. Without this the call
|
|
// would land on the workspace root which for multi-repo task workspaces
|
|
// isn't a git repo — and `git diff` would silently ascend to whatever .git
|
|
// encloses the task root (e.g. the user's outer kandev checkout) and return
|
|
// hundreds of unrelated files.
|
|
func (s *Server) handleGitCumulativeDiff(c *gin.Context) {
|
|
var req GitCumulativeDiffRequest
|
|
if err := c.ShouldBindQuery(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, process.CumulativeDiffResult{
|
|
Success: false,
|
|
Error: "invalid request: " + err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Same untrusted-ref guard as handleGitLog: inline the
|
|
// securityutil.IsValidBranchName check at the sink so static analysis
|
|
// sees the regex barrier in the same function as the downstream
|
|
// subprocess call paths.
|
|
check, hasOriginPrefix := strings.CutPrefix(req.TargetBranch, "origin/")
|
|
if !hasOriginPrefix {
|
|
check = req.TargetBranch
|
|
}
|
|
if !safeBranchRefPattern.MatchString(check) || strings.Contains(check, "..") || strings.HasSuffix(check, ".lock") {
|
|
req.TargetBranch = ""
|
|
}
|
|
|
|
if req.Repo == "" {
|
|
if scopes := s.procMgr.RepositoryScopes(); shouldFanOutRepositoryScopes(scopes) {
|
|
s.handleGitCumulativeDiffMultiRepo(c, req, scopes)
|
|
return
|
|
}
|
|
}
|
|
|
|
result, status, err := s.runGitCumulativeDiffForRepo(c.Request.Context(), req.Base, req.TargetBranch, req.Repo)
|
|
if err != nil {
|
|
c.JSON(status, process.CumulativeDiffResult{
|
|
Success: false,
|
|
Error: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
// runGitCumulativeDiffForRepo runs cumulative diff against a single repo
|
|
// subpath. Returns nil after writing an error response on lookup failures;
|
|
// otherwise returns the (possibly success=false) result for the caller.
|
|
//
|
|
// When targetBranch is non-empty the base is recomputed dynamically via
|
|
// merge-base against origin/<targetBranch> (with a local-ref fallback). This
|
|
// matches the COMMITS panel: anchor to live divergence with the upstream so
|
|
// the diff updates as main moves forward and excludes file changes brought
|
|
// in via merges from main.
|
|
func (s *Server) runGitCumulativeDiffForRepo(
|
|
ctx context.Context,
|
|
base, targetBranch, repo string,
|
|
) (*process.CumulativeDiffResult, int, error) {
|
|
gitOp, gitOpErr := s.procMgr.GitOperatorFor(repo)
|
|
if gitOpErr != nil {
|
|
return nil, http.StatusBadRequest, gitOpErr
|
|
}
|
|
comparison, comparisonErr := s.comparisonResolutionFor(repo)
|
|
if comparisonErr != nil {
|
|
return nil, http.StatusBadRequest, comparisonErr
|
|
}
|
|
return s.runGitCumulativeDiffForRepoResolved(ctx, gitOp, base, targetBranch, repo, comparison)
|
|
}
|
|
|
|
func (s *Server) runGitCumulativeDiffForRepoResolved(
|
|
ctx context.Context,
|
|
gitOp *process.GitOperator,
|
|
base, targetBranch, repo string,
|
|
comparison process.ComparisonResolution,
|
|
) (*process.CumulativeDiffResult, int, error) {
|
|
if comparison.Explicit {
|
|
if comparison.Status != lspStatusReady || comparison.Ref == "" {
|
|
return comparisonUnavailableDiffResult(comparison.ErrorCode), http.StatusOK, nil
|
|
}
|
|
mergeBase, err := gitOp.GetMergeBase(ctx, "HEAD", comparison.Ref)
|
|
if err != nil || mergeBase == "" {
|
|
return comparisonUnavailableDiffResult("comparison_merge_base_unavailable"), http.StatusOK, nil
|
|
}
|
|
result, err := gitOp.GetCumulativeDiff(ctx, mergeBase)
|
|
return result, http.StatusOK, err
|
|
}
|
|
if targetBranch != "" {
|
|
switch mb, err := s.computeMergeBase(ctx, gitOp, targetBranch); {
|
|
case err != nil:
|
|
s.logger.Warn("cumulative diff: merge-base failed, using stored base",
|
|
zap.String("target_branch", targetBranch),
|
|
zap.String("repo", repo),
|
|
zap.Error(err))
|
|
case mb == "":
|
|
// merge-base returned no error but no SHA — happens when HEAD and
|
|
// targetBranch share no history. Log it so the silent fallback to
|
|
// the stored base is visible during diagnostics.
|
|
s.logger.Warn("cumulative diff: merge-base returned empty, using stored base",
|
|
zap.String("target_branch", targetBranch),
|
|
zap.String("repo", repo))
|
|
default:
|
|
base = mb
|
|
}
|
|
// Mirror the commits panel: re-anchor a stale stored base (merged/
|
|
// deleted stacked parent, rebase) to the live integration merge-base so
|
|
// the diff excludes changes that already landed on the integration
|
|
// branch. Only fires when the target has no live upstream ref. No-op
|
|
// when the base is already current.
|
|
base = gitOp.CorrectStaleComparisonBase(ctx, base, targetBranch)
|
|
}
|
|
result, err := gitOp.GetCumulativeDiff(ctx, base)
|
|
if err != nil {
|
|
s.logger.Error("git cumulative diff failed",
|
|
zap.String("base", base),
|
|
zap.String("repo", repo),
|
|
zap.Error(err))
|
|
return nil, http.StatusInternalServerError, err
|
|
}
|
|
return result, http.StatusOK, nil
|
|
}
|
|
|
|
// handleGitCumulativeDiffMultiRepo fans cumulative diff out across every
|
|
// per-repo tracker. Each repo's base commit is resolved from its configured
|
|
// task base branch — the caller-supplied base only makes sense for one repo at
|
|
// a time, since each repo has its own commit graph. Files from each repo are
|
|
// merged into a single map, keyed by
|
|
// `<repoSubpath>/<path>` so paths that exist in multiple repos don't clash,
|
|
// and tagged with `repository_name` so the frontend can group them.
|
|
func (s *Server) handleGitCumulativeDiffMultiRepo(
|
|
c *gin.Context,
|
|
req GitCumulativeDiffRequest,
|
|
subpaths []string,
|
|
) {
|
|
// Parallel fan-out for the same reason as handleGitLogMultiRepo: each repo
|
|
// is an independent git invocation, and serially the request took the sum
|
|
// over repositories, which on Windows is enough to blow the gateway's 60s
|
|
// budget on a task with many repos.
|
|
ctx := c.Request.Context()
|
|
outcomes := fanOutRepos(ctx, subpaths, func(ctx context.Context, sub string) perRepoDiffOutcome {
|
|
return s.collectCumulativeDiffForRepo(ctx, sub)
|
|
})
|
|
|
|
// Merging stays sequential: it writes into one shared map, and doing it in
|
|
// subpath order keeps the merged output independent of goroutine timing.
|
|
merged := process.CumulativeDiffResult{
|
|
Files: make(map[string]interface{}),
|
|
Success: true,
|
|
}
|
|
anyOK := false
|
|
comparisonErrorCode := ""
|
|
for _, outcome := range outcomes {
|
|
// A hard failure still aborts the whole request, as it did serially.
|
|
// Reporting the first one in subpath order rather than the first to
|
|
// occur keeps the response deterministic now that the repos race.
|
|
if outcome.err != nil {
|
|
c.JSON(outcome.status, process.CumulativeDiffResult{
|
|
Success: false,
|
|
Error: outcome.err.Error(),
|
|
})
|
|
return
|
|
}
|
|
if outcome.result == nil {
|
|
continue // no per-repo base; already logged
|
|
}
|
|
if !outcome.result.Success {
|
|
s.logger.Warn("cumulative diff for repo returned failure",
|
|
zap.String("repo", outcome.subpath),
|
|
zap.String("error", outcome.result.Error))
|
|
if comparisonErrorCode == "" {
|
|
comparisonErrorCode = outcome.result.ErrorCode
|
|
}
|
|
continue
|
|
}
|
|
anyOK = true
|
|
merged.TotalCommits += outcome.result.TotalCommits
|
|
mergeCumulativeFiles(
|
|
merged.Files,
|
|
outcome.result.Files,
|
|
outcome.subpath,
|
|
outcome.result.BaseCommit,
|
|
outcome.isSubmodule,
|
|
)
|
|
}
|
|
if !anyOK {
|
|
merged.Success = false
|
|
merged.Error = fmt.Sprintf("cumulative diff failed for all %d repositories", len(subpaths))
|
|
merged.ErrorCode = comparisonErrorCode
|
|
}
|
|
c.JSON(http.StatusOK, merged)
|
|
}
|
|
|
|
// perRepoDiffOutcome carries one repository's cumulative-diff result out of the
|
|
// fan-out. A nil result with a nil error means the repo had no resolvable base
|
|
// and was skipped; a non-nil error is a hard failure that aborts the request,
|
|
// with status holding the code the serial version would have written.
|
|
type perRepoDiffOutcome struct {
|
|
subpath string
|
|
result *process.CumulativeDiffResult
|
|
status int
|
|
err error
|
|
isSubmodule bool
|
|
}
|
|
|
|
// collectCumulativeDiffForRepo runs the cumulative diff for one repository of a
|
|
// multi-repo fan-out. Like collectLogForRepo it takes a plain context and never
|
|
// writes to the gin context, so it is safe to run concurrently.
|
|
func (s *Server) collectCumulativeDiffForRepo(ctx context.Context, sub string) perRepoDiffOutcome {
|
|
comparison, comparisonErr := s.comparisonResolutionFor(sub)
|
|
if comparisonErr != nil {
|
|
return perRepoDiffOutcome{subpath: sub, status: http.StatusBadRequest, err: comparisonErr}
|
|
}
|
|
if comparison.Explicit {
|
|
gitOp, gitOpErr := s.procMgr.GitOperatorFor(sub)
|
|
if gitOpErr != nil {
|
|
return perRepoDiffOutcome{subpath: sub, status: http.StatusBadRequest, err: gitOpErr}
|
|
}
|
|
result, status, err := s.runGitCumulativeDiffForRepoResolved(ctx, gitOp, "", "", sub, comparison)
|
|
return perRepoDiffOutcome{subpath: sub, result: result, status: status, err: err}
|
|
}
|
|
base, isSubmodule := s.resolvePerRepoBaseAndScope(ctx, sub)
|
|
if base == "" {
|
|
s.logger.Warn("cumulative diff: no per-repo base, skipping",
|
|
zap.String("repo", sub))
|
|
return perRepoDiffOutcome{subpath: sub, isSubmodule: isSubmodule}
|
|
}
|
|
// Multi-repo: base is already resolved per-repo via resolvePerRepoBase,
|
|
// so we pass empty target_branch to skip the second merge-base attempt.
|
|
gitOp, gitOpErr := s.procMgr.GitOperatorFor(sub)
|
|
if gitOpErr != nil {
|
|
return perRepoDiffOutcome{subpath: sub, status: http.StatusBadRequest, err: gitOpErr, isSubmodule: isSubmodule}
|
|
}
|
|
result, status, err := s.runGitCumulativeDiffForRepoResolved(ctx, gitOp, base, "", sub, comparison)
|
|
return perRepoDiffOutcome{
|
|
subpath: sub,
|
|
result: result,
|
|
status: status,
|
|
err: err,
|
|
isSubmodule: isSubmodule,
|
|
}
|
|
}
|
|
|
|
// fanOutRepos runs collect once per repository, concurrently, and returns the
|
|
// results in subpath order regardless of which finished first. Every repository
|
|
// is an independent git invocation against its own worktree, so serial
|
|
// execution made request latency the sum over repositories rather than the
|
|
// slowest one — on Windows, where a git spawn costs ~150ms before doing any
|
|
// work, a task with a dozen repositories could exceed the gateway's 60s budget
|
|
// and the caller would see a timeout instead of a slow answer.
|
|
//
|
|
// Concurrency is bounded by the process-wide Git admission capacity shared with
|
|
// background polling and lifecycle work. The admission controller remains the
|
|
// authority on active subprocesses; this limit only bounds goroutines and queued
|
|
// repository work created by one request.
|
|
|
|
func fanOutRepos[T any](ctx context.Context, subpaths []string, collect func(context.Context, string) T) []T {
|
|
results := make([]T, len(subpaths))
|
|
var group errgroup.Group
|
|
limit := len(subpaths)
|
|
if capacity := subproc.GitCapacity(); capacity > 0 && capacity < limit {
|
|
limit = capacity
|
|
}
|
|
group.SetLimit(limit)
|
|
for i, sub := range subpaths {
|
|
group.Go(func() error {
|
|
// collect owns the per-repository result and never returns an
|
|
// errgroup error. Preserve the caller's context identity here so
|
|
// request cancellation and context-aware singleflight observers see
|
|
// the original request boundary rather than an internal wrapper.
|
|
results[i] = collect(ctx, sub)
|
|
return nil
|
|
})
|
|
}
|
|
// collect never returns an error — per-repository failures travel inside T
|
|
// so the merge can report them — so Wait only surfaces context cancellation.
|
|
_ = group.Wait()
|
|
return results
|
|
}
|
|
|
|
// resolvePerRepoBase returns the comparison anchor owned by the repository's
|
|
// workspace tracker. Multi-repo tasks share no single base commit across
|
|
// repos, and each tracker carries that repository's configured task base.
|
|
//
|
|
// The single-repo path re-anchors a stale stored base from req.TargetBranch,
|
|
// but the multi-repo fan-out clears TargetBranch, so it must apply the shared
|
|
// comparison policy here using the tracker's resolved base branch.
|
|
// Without this, a repo whose base branch is a merged/deleted stacked parent
|
|
// lingering only as a local ref keeps inflating the commit count and diff.
|
|
func (s *Server) resolvePerRepoBase(ctx context.Context, repo string) string {
|
|
base, _ := s.resolvePerRepoBaseAndScope(ctx, repo)
|
|
return base
|
|
}
|
|
|
|
// resolvePerRepoBaseAndScope returns the repository's comparison anchor and
|
|
// whether its workspace is a submodule. The metadata travels with cumulative
|
|
// diff results so the frontend can distinguish nested repository scopes from
|
|
// ordinary sibling repositories that merely happen to have a named path.
|
|
func (s *Server) resolvePerRepoBaseAndScope(ctx context.Context, repo string) (string, bool) {
|
|
tracker, err := s.procMgr.GetWorkspaceTrackerFor(repo)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
isSubmodule := tracker.IsSubmodule()
|
|
base, baseBranch := tracker.ResolveBaseAnchor(ctx)
|
|
if base == "" || baseBranch == "" {
|
|
return base, isSubmodule
|
|
}
|
|
if isSubmodule {
|
|
return base, true
|
|
}
|
|
gitOp, gitOpErr := s.procMgr.GitOperatorFor(repo)
|
|
if gitOpErr != nil {
|
|
return base, false
|
|
}
|
|
return gitOp.CorrectStaleComparisonBase(ctx, base, baseBranch), false
|
|
}
|
|
|
|
// mergeCumulativeFiles copies per-repo files into the merged map under a
|
|
// `<repo> <path>` key (NUL-separated) and decorates each file payload
|
|
// with `repository_name`, the repo-relative `path`, the exact old-side
|
|
// `base_ref` used to produce its cumulative diff, and (for nested repository
|
|
// scopes) `is_submodule`.
|
|
// The composite key keeps `README.md` in two repos from clashing in the map;
|
|
// the frontend reads `path` and `repository_name` off the payload so the
|
|
// file tree groups under the repo header without the prefix bleeding into
|
|
// the displayed path. NUL is impossible in real paths, so the key is
|
|
// always uniquely splittable and the displayed path is unaffected.
|
|
func mergeCumulativeFiles(dst, src map[string]interface{}, repo, baseRef string, isSubmodule bool) {
|
|
for path, payload := range src {
|
|
m, ok := payload.(map[string]interface{})
|
|
if !ok {
|
|
// Defensive: only known shape from parseCommitDiff is map[string]interface{}.
|
|
// If a future change emits something else, route it through unchanged
|
|
// rather than silently dropping the path/repo metadata.
|
|
dst[fmt.Sprintf("%s\x00%s", repo, path)] = payload
|
|
continue
|
|
}
|
|
// Shallow-copy the per-file map before stamping repository_name,
|
|
// path, and base_ref so the caller's source map isn't mutated. Earlier
|
|
// code wrote directly to `m`, which permanently rewrote the per-repo result
|
|
// before it could be reused (e.g. emitted to a second consumer).
|
|
copied := make(map[string]interface{}, len(m)+4)
|
|
for k, v := range m {
|
|
copied[k] = v
|
|
}
|
|
copied["repository_name"] = repo
|
|
copied["path"] = path
|
|
if baseRef != "" {
|
|
copied["base_ref"] = baseRef
|
|
}
|
|
if isSubmodule {
|
|
copied["is_submodule"] = true
|
|
}
|
|
dst[fmt.Sprintf("%s\x00%s", repo, path)] = copied
|
|
}
|
|
}
|
|
|
|
// GitStatusResult represents the result of a git status query.
|
|
type GitStatusResult struct {
|
|
Success bool `json:"success"`
|
|
RepositoryName string `json:"repository_name,omitempty"`
|
|
IsSubmodule bool `json:"is_submodule,omitempty"`
|
|
Branch string `json:"branch"`
|
|
RemoteBranch string `json:"remote_branch"`
|
|
HeadCommit string `json:"head_commit"`
|
|
BaseCommit string `json:"base_commit"` // Merge-base with origin branch
|
|
ComparisonTarget string `json:"comparison_target,omitempty"`
|
|
ComparisonStatus string `json:"comparison_status,omitempty"`
|
|
ComparisonErrorCode string `json:"comparison_error_code,omitempty"`
|
|
Ahead int `json:"ahead"`
|
|
Behind int `json:"behind"`
|
|
RemoteAhead int `json:"remote_ahead"`
|
|
RemoteBehind int `json:"remote_behind"`
|
|
RemoteHeadCommit string `json:"remote_head_commit,omitempty"`
|
|
Modified []string `json:"modified"`
|
|
Added []string `json:"added"`
|
|
Deleted []string `json:"deleted"`
|
|
Untracked []string `json:"untracked"`
|
|
Renamed []string `json:"renamed"`
|
|
Files map[string]interface{} `json:"files"`
|
|
Timestamp string `json:"timestamp"`
|
|
BranchAdditions int `json:"branch_additions,omitempty"`
|
|
BranchDeletions int `json:"branch_deletions,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// PerRepoGitStatus pairs a repository_name with its current status. Used by
|
|
// the multi-repo status fan-out so callers (notably the gateway's session
|
|
// subscribe handler) can deliver one stamped GitStatusUpdate per repo on
|
|
// reconnect — without it the frontend would only see the workspace-root
|
|
// (untagged) status and miss the per-repo grouping.
|
|
type PerRepoGitStatus struct {
|
|
RepositoryName string `json:"repository_name"`
|
|
Status GitStatusResult `json:"status"`
|
|
}
|
|
|
|
// MultiRepoGitStatusResult is the response shape for /api/v1/git/status/multi.
|
|
// For multi-repo task workspaces the response carries one entry per repo,
|
|
// stamped with its name. Single-repo workspaces return a single entry with an
|
|
// empty repository_name so callers can treat both shapes uniformly.
|
|
type MultiRepoGitStatusResult struct {
|
|
Success bool `json:"success"`
|
|
Repos []PerRepoGitStatus `json:"repos"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
func shouldFanOutRepositoryScopes(scopes []string) bool {
|
|
if len(scopes) == 0 {
|
|
return false
|
|
}
|
|
return len(scopes) > 1 || scopes[0] != ""
|
|
}
|
|
|
|
func requiresExplicitRepositoryScope(scopes []string) bool {
|
|
return len(scopes) > 0 && scopes[0] != ""
|
|
}
|
|
|
|
// handleGitStatusMulti returns one git status entry per repo for multi-repo
|
|
// task workspaces (or one untagged entry for single-repo). Used by the
|
|
// session-subscribe handler in the main backend to seed per-repo state on
|
|
// page reload — the legacy single GET /api/v1/git/status endpoint returns
|
|
// only the workspace-root status, which is empty for multi-repo task roots
|
|
// (the root isn't itself a git repo). Pass ?fresh=true to bypass the cached
|
|
// status and run a fresh git query — used on WS subscribe so a new observer
|
|
// always validates the cache against the live worktree.
|
|
func (s *Server) handleGitStatusMulti(c *gin.Context) {
|
|
subpaths := s.procMgr.RepositoryScopes()
|
|
// Single-repo: fall back to the workspace-root status with an empty repo
|
|
// name so the response shape stays uniform.
|
|
if len(subpaths) == 0 {
|
|
subpaths = []string{""}
|
|
}
|
|
fresh := c.Query("fresh") == queryParamTrue
|
|
// Parallel fan-out: fresh=true skips the cache, so serial scales linearly and would blow the 2s subscribe timeout for multi-repo workspaces.
|
|
result := MultiRepoGitStatusResult{Success: true, Repos: make([]PerRepoGitStatus, len(subpaths))}
|
|
ctx := c.Request.Context()
|
|
outcomes := fanOutRepos(ctx, subpaths, func(ctx context.Context, sub string) PerRepoGitStatus {
|
|
return s.collectStatusForRepo(ctx, sub, fresh)
|
|
})
|
|
copy(result.Repos, outcomes)
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
// collectStatusForRepo runs the status query for a single subpath and packs
|
|
// it into a PerRepoGitStatus. When fresh is true the workspace tracker
|
|
// re-runs `git status --porcelain` against the worktree instead of returning
|
|
// the cached snapshot. Failures land in Status.Error / Status.Success so the
|
|
// caller can render partial results instead of erroring out the whole fan-out
|
|
// when one repo is misconfigured.
|
|
func (s *Server) collectStatusForRepo(ctx context.Context, sub string, fresh bool) PerRepoGitStatus {
|
|
wt, wtErr := s.procMgr.GetWorkspaceTrackerFor(sub)
|
|
if wtErr != nil {
|
|
return PerRepoGitStatus{
|
|
RepositoryName: sub,
|
|
Status: GitStatusResult{Success: false, Error: wtErr.Error()},
|
|
}
|
|
}
|
|
if wt == nil {
|
|
return PerRepoGitStatus{
|
|
RepositoryName: sub,
|
|
Status: GitStatusResult{Success: false, Error: "workspace tracker not available"},
|
|
}
|
|
}
|
|
status, err := wt.GetGitStatus(ctx, fresh)
|
|
if err != nil {
|
|
return PerRepoGitStatus{
|
|
RepositoryName: sub,
|
|
Status: GitStatusResult{Success: false, Error: err.Error()},
|
|
}
|
|
}
|
|
filesMap := make(map[string]interface{}, len(status.Files))
|
|
for k, v := range status.Files {
|
|
filesMap[k] = v
|
|
}
|
|
return PerRepoGitStatus{
|
|
RepositoryName: sub,
|
|
Status: GitStatusResult{
|
|
Success: true,
|
|
RepositoryName: sub,
|
|
IsSubmodule: status.IsSubmodule,
|
|
Branch: status.Branch,
|
|
RemoteBranch: status.RemoteBranch,
|
|
HeadCommit: status.HeadCommit,
|
|
BaseCommit: status.BaseCommit,
|
|
ComparisonTarget: status.ComparisonTarget,
|
|
ComparisonStatus: status.ComparisonStatus,
|
|
ComparisonErrorCode: status.ComparisonErrorCode,
|
|
Ahead: status.Ahead,
|
|
Behind: status.Behind,
|
|
RemoteAhead: status.RemoteAhead,
|
|
RemoteBehind: status.RemoteBehind,
|
|
RemoteHeadCommit: status.RemoteHeadCommit,
|
|
Modified: status.Modified,
|
|
Added: status.Added,
|
|
Deleted: status.Deleted,
|
|
Untracked: status.Untracked,
|
|
Renamed: status.Renamed,
|
|
Files: filesMap,
|
|
Timestamp: status.Timestamp.Format("2006-01-02T15:04:05.000Z07:00"),
|
|
BranchAdditions: status.BranchAdditions,
|
|
BranchDeletions: status.BranchDeletions,
|
|
},
|
|
}
|
|
}
|
|
|
|
// handleGitStatus handles GET /api/v1/git/status. Accepts an optional
|
|
// ?repo=<subpath> query param that selects a sub-directory of the workspace
|
|
// for multi-repo task roots; empty = workspace root (single-repo behavior).
|
|
// Pass ?fresh=true to bypass the cached status and run a fresh git query.
|
|
func (s *Server) handleGitStatus(c *gin.Context) {
|
|
wt, wtErr := s.procMgr.GetWorkspaceTrackerFor(c.Query("repo"))
|
|
if wtErr != nil {
|
|
c.JSON(http.StatusBadRequest, GitStatusResult{
|
|
Success: false,
|
|
Error: wtErr.Error(),
|
|
})
|
|
return
|
|
}
|
|
if wt == nil {
|
|
c.JSON(http.StatusInternalServerError, GitStatusResult{
|
|
Success: false,
|
|
Error: "workspace tracker not available",
|
|
})
|
|
return
|
|
}
|
|
|
|
status, err := wt.GetGitStatus(c.Request.Context(), c.Query("fresh") == queryParamTrue)
|
|
if err != nil {
|
|
s.logger.Error("git status failed", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, GitStatusResult{
|
|
Success: false,
|
|
Error: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Convert Files map to interface{} map for JSON serialization
|
|
filesMap := make(map[string]interface{}, len(status.Files))
|
|
for k, v := range status.Files {
|
|
filesMap[k] = v
|
|
}
|
|
|
|
c.JSON(http.StatusOK, GitStatusResult{
|
|
Success: true,
|
|
RepositoryName: c.Query("repo"),
|
|
IsSubmodule: status.IsSubmodule,
|
|
Branch: status.Branch,
|
|
RemoteBranch: status.RemoteBranch,
|
|
HeadCommit: status.HeadCommit,
|
|
BaseCommit: status.BaseCommit,
|
|
ComparisonTarget: status.ComparisonTarget,
|
|
ComparisonStatus: status.ComparisonStatus,
|
|
ComparisonErrorCode: status.ComparisonErrorCode,
|
|
Ahead: status.Ahead,
|
|
Behind: status.Behind,
|
|
RemoteAhead: status.RemoteAhead,
|
|
RemoteBehind: status.RemoteBehind,
|
|
RemoteHeadCommit: status.RemoteHeadCommit,
|
|
Modified: status.Modified,
|
|
Added: status.Added,
|
|
Deleted: status.Deleted,
|
|
Untracked: status.Untracked,
|
|
Renamed: status.Renamed,
|
|
Files: filesMap,
|
|
Timestamp: status.Timestamp.Format("2006-01-02T15:04:05.000Z07:00"),
|
|
BranchAdditions: status.BranchAdditions,
|
|
BranchDeletions: status.BranchDeletions,
|
|
})
|
|
}
|
|
|
|
// 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(),
|
|
})
|
|
}
|