Files
kandev/apps/backend/internal/agentctl/server/api/git.go
T
Kandev Agent 9e8079de69 feat(multi-repo): per-repo git ops, unified branch endpoint, chip-style task create
Bundles a string of multi-repo fixes and refactors that came up while
testing the feature end-to-end:

- workspace_path stored on multi-repo TaskEnvironment was one level too
  high, so agentctl booted with the wrong WorkDir on cold start; fixed
  in resolveTaskEnvWorkspacePath + GetWorkspaceInfoForSession.
- Stage/Unstage/Discard/Commit/Revert/Reset/Amend now thread `repo`
  through agentctl client → WS handler → frontend hook → SessionGit
  fan-out, so the right git repo is targeted (was running at the task
  root, which isn't a git repo for multi-repo tasks).
- agentctl GitLog is multi-repo aware: when no `repo` is specified and
  per-repo trackers exist, fans out one log per repo and merges sorted
  newest-first with RepositoryName stamped on each commit.
- Per-repo grouping for files (Changes panel) and commits, with
  collapsible group headers; commits section defaults to collapsed.
  Algorithm extracted into one generic groupByRepositoryName helper
  (was copy-pasted three times).
- Task creation dialog rewritten around a unified `fs.repositories[]`
  list — no more "primary vs extras" split. Chip-style row with one
  pill per repo + branch, "+" to add, x to remove, GitHub URL toggle
  inline. Discovered (on-machine) repos appear in any chip's dropdown.
  Removes ~6 fields and 3 helpers from the dialog state.
- Branch listing endpoints collapsed to one:
  GET /workspaces/:id/branches?repository_id=X | path=Y
  Frontend collapses to a single useBranches hook with a tagged-union
  source. Drops two HTTP routes, two service methods, two API client
  funcs, and one whole hook file.

Tests added across the chain: workspace_path resolver, sort-commits-
by-date, manager poll-mode propagation, RepoSubpaths, chip grouping
+ collapse + per-repo isLatest, group-by-repo helper, dialog payload
builder + chip wiring + discovered-repo flow.
2026-04-26 21:23:48 +01:00

868 lines
23 KiB
Go

package api
import (
"errors"
"fmt"
"net/http"
"sort"
"time"
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/agentctl/server/process"
"go.uber.org/zap"
)
// 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
type GitPushRequest struct {
Force bool `json:"force"`
SetUpstream bool `json:"set_upstream"`
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
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(), 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.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 subs := s.procMgr.RepoSubpaths(); len(subs) > 0 {
s.handleGitLogMultiRepo(c, req, subs, limit)
return
}
}
result, err := s.runGitLogForRepo(c, req, limit, req.Repo)
if err != nil {
s.handleGitError(c, "log", err)
return
}
c.JSON(http.StatusOK, result)
}
// 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(
c *gin.Context,
req GitLogRequest,
limit int,
repo string,
) (*process.GitLogResult, error) {
gitOp, gitOpErr := s.procMgr.GitOperatorFor(repo)
if gitOpErr != nil {
return nil, gitOpErr
}
baseCommit := req.Since
if req.TargetBranch != "" {
mergeBase, err := gitOp.GetMergeBase(c.Request.Context(), "HEAD", req.TargetBranch)
if err != nil {
s.logger.Warn("failed to compute merge-base, falling back to since",
zap.String("target_branch", req.TargetBranch),
zap.String("repo", repo),
zap.Error(err))
} else if mergeBase != "" {
baseCommit = mergeBase
}
}
return gitOp.GetLog(c.Request.Context(), baseCommit, limit)
}
// 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.
func (s *Server) handleGitLogMultiRepo(
c *gin.Context,
req GitLogRequest,
subpaths []string,
limit int,
) {
merged := make([]*process.GitCommitInfo, 0)
for _, sub := range subpaths {
result, err := s.runGitLogForRepo(c, req, limit, sub)
if err != nil {
s.logger.Warn("git log for repo failed; skipping",
zap.String("repo", sub), zap.Error(err))
continue
}
if !result.Success {
s.logger.Warn("git log for repo returned failure; skipping",
zap.String("repo", sub), zap.String("error", result.Error))
continue
}
for _, commit := range result.Commits {
commit.RepositoryName = sub
merged = append(merged, commit)
}
}
// Sort newest-first across repos. Falls back to insertion order if either
// timestamp is unparseable.
sortCommitsByCommittedAtDesc(merged)
if len(merged) > limit {
merged = merged[:limit]
}
c.JSON(http.StatusOK, process.GitLogResult{
Success: true,
Commits: merged,
})
}
// handleGitCumulativeDiff handles GET /api/v1/git/cumulative-diff
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
}
gitOp, gitOpErr := s.procMgr.GitOperatorFor(req.Repo)
if gitOpErr != nil {
c.JSON(http.StatusBadRequest, process.CumulativeDiffResult{
Success: false,
Error: gitOpErr.Error(),
})
return
}
result, err := gitOp.GetCumulativeDiff(c.Request.Context(), req.Base)
if err != nil {
s.logger.Error("git cumulative diff failed", zap.String("base", req.Base), zap.Error(err))
c.JSON(http.StatusInternalServerError, process.CumulativeDiffResult{
Success: false,
Error: err.Error(),
})
return
}
c.JSON(http.StatusOK, result)
}
// GitStatusResult represents the result of a git status query.
type GitStatusResult struct {
Success bool `json:"success"`
Branch string `json:"branch"`
RemoteBranch string `json:"remote_branch"`
HeadCommit string `json:"head_commit"`
BaseCommit string `json:"base_commit"` // Merge-base with origin branch
Ahead int `json:"ahead"`
Behind int `json:"behind"`
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"`
}
// 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).
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.GetCurrentGitStatus(c.Request.Context())
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,
Branch: status.Branch,
RemoteBranch: status.RemoteBranch,
HeadCommit: status.HeadCommit,
BaseCommit: status.BaseCommit,
Ahead: status.Ahead,
Behind: status.Behind,
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(),
})
}