feat: explain divergent contribution histories (#3627)

This commit is contained in:
Carlos Florêncio
2026-09-14 09:28:23 +01:00
committed by GitHub
parent f371804f5b
commit 5c6434d7d8
65 changed files with 5376 additions and 356 deletions
@@ -0,0 +1,63 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"testing"
ws "github.com/kandev/kandev/pkg/websocket"
)
func TestContributionHistoryHandler(t *testing.T) {
h, server := gitHandlerServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/git/contribution/history-explanation" {
t.Errorf("path = %s, want history explanation endpoint", r.URL.Path)
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Errorf("decode request: %v", err)
}
if body["branch"] != "feature/history" || body["repo"] != "svc" {
t.Errorf("body = %#v, want branch and repo", body)
}
_, _ = w.Write([]byte(`{
"repo":"svc",
"branch":"feature/history",
"expected_local_head":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"expected_remote_head":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"kind":"unexplained",
"reason":"no_matching_reflog"
}`))
})
defer server.Close()
msg, err := ws.NewRequest("id", ws.ActionWorktreeContributionHistoryExplanation, GitContributionHistoryExplanationRequest{
SessionID: "s",
Repo: "svc",
Branch: "feature/history",
ExpectedLocalHead: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
ExpectedRemoteHead: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
})
if err != nil {
t.Fatal(err)
}
response, err := h.wsContributionHistoryExplanation(context.Background(), msg)
if err != nil {
t.Fatalf("handler: %v", err)
}
if response == nil || !json.Valid(response.Payload) {
t.Fatalf("response = %#v, want JSON payload", response)
}
}
func TestContributionHistoryHandlerRejectsMissingIdentity(t *testing.T) {
h := NewGitHandlers(nil, nil, newTestLogger())
msg, err := ws.NewRequest("id", ws.ActionWorktreeContributionHistoryExplanation, GitContributionHistoryExplanationRequest{SessionID: "s"})
if err != nil {
t.Fatal(err)
}
if _, err := h.wsContributionHistoryExplanation(context.Background(), msg); err == nil {
t.Fatal("expected missing identity validation error")
}
}
@@ -153,6 +153,7 @@ func (h *GitHandlers) RegisterHandlers(d *ws.Dispatcher) {
d.RegisterFunc(ws.ActionWorktreePush, h.wsPush)
d.RegisterFunc(ws.ActionWorktreeReplaceContribution, h.wsReplaceContribution)
d.RegisterFunc(ws.ActionWorktreeUseContribution, h.wsUseContribution)
d.RegisterFunc(ws.ActionWorktreeContributionHistoryExplanation, h.wsContributionHistoryExplanation)
d.RegisterFunc(ws.ActionWorktreeRebase, h.wsRebase)
d.RegisterFunc(ws.ActionWorktreeMerge, h.wsMerge)
d.RegisterFunc(ws.ActionWorktreeAbort, h.wsAbort)
@@ -198,6 +199,16 @@ type GitContributionRequest struct {
Repo string `json:"repo,omitempty"`
}
// GitContributionHistoryExplanationRequest carries the selected branch and
// both immutable heads for a read-only contribution history observation.
type GitContributionHistoryExplanationRequest struct {
SessionID string `json:"session_id"`
Repo string `json:"repo,omitempty"`
Branch string `json:"branch"`
ExpectedLocalHead string `json:"expected_local_head"`
ExpectedRemoteHead string `json:"expected_remote_head"`
}
// GitRebaseRequest for worktree.rebase action.
// Repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces.
type GitRebaseRequest struct {
@@ -367,6 +378,37 @@ func (h *GitHandlers) wsUseContribution(ctx context.Context, msg *ws.Message) (*
})
}
func (h *GitHandlers) wsContributionHistoryExplanation(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req GitContributionHistoryExplanationRequest
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")
}
for field, value := range map[string]string{
"branch": req.Branch,
"expected_local_head": req.ExpectedLocalHead,
"expected_remote_head": req.ExpectedRemoteHead,
} {
if value == "" {
return nil, fmt.Errorf("%s is required", field)
}
}
agentClient, releaseClient, err := h.getAgentCtlClient(ctx, req.SessionID)
defer releaseClient()
if err != nil {
return nil, err
}
result, err := agentClient.GitContributionHistoryExplanation(
ctx, req.Branch, req.ExpectedLocalHead, req.ExpectedRemoteHead, req.Repo)
if err != nil {
return nil, fmt.Errorf("contribution history explanation failed: %w", err)
}
return ws.NewResponse(msg.ID, msg.Action, result)
}
func (h *GitHandlers) wsContribution(ctx context.Context, msg *ws.Message, operation string, action func(*client.Client, string, string) (*client.GitOperationResult, error)) (*ws.Message, error) {
var req GitContributionRequest
if err := msg.ParsePayload(&req); err != nil {
@@ -424,6 +424,7 @@ func TestRegisterGitHandlers(t *testing.T) {
actions := []string{
ws.ActionWorktreePull,
ws.ActionWorktreePush,
ws.ActionWorktreeContributionHistoryExplanation,
ws.ActionWorktreeRebase,
ws.ActionWorktreeMerge,
ws.ActionWorktreeAbort,
@@ -47,6 +47,21 @@ type PushOptions struct {
ExpectedBranch string
}
// ContributionHistoryExplanationResult mirrors the bounded, read-only Git
// observation returned by the agentctl server.
type ContributionHistoryExplanationResult struct {
Repo string `json:"repo,omitempty"`
Branch string `json:"branch"`
ExpectedLocalHead string `json:"expected_local_head"`
ExpectedRemoteHead string `json:"expected_remote_head"`
Kind string `json:"kind"`
Reason string `json:"reason"`
OntoHead string `json:"onto_head,omitempty"`
TaskCommitCount *int `json:"task_commit_count,omitempty"`
PublishedCommitCount *int `json:"published_commit_count,omitempty"`
NewBaseCommitCount *int `json:"new_base_commit_count,omitempty"`
}
// PRCreateResult represents the result of a PR creation operation.
// This matches the server-side process.PRCreateResult.
type PRCreateResult struct {
@@ -143,6 +158,52 @@ func (c *Client) GitUseRemoteContribution(ctx context.Context, expectedRemoteHea
return c.gitOperation(ctx, "/api/v1/git/contribution/use", payload)
}
// GitContributionHistoryExplanation observes the selected branch and heads
// without fetching, rewriting, or publishing any Git refs.
func (c *Client) GitContributionHistoryExplanation(
ctx context.Context, branch, expectedLocalHead, expectedRemoteHead, repo string,
) (*ContributionHistoryExplanationResult, error) {
payload := struct {
Branch string `json:"branch"`
ExpectedLocalHead string `json:"expected_local_head"`
ExpectedRemoteHead string `json:"expected_remote_head"`
Repo string `json:"repo,omitempty"`
}{
Branch: branch,
ExpectedLocalHead: expectedLocalHead,
ExpectedRemoteHead: expectedRemoteHead,
Repo: repo,
}
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.baseURL+"/api/v1/git/contribution/history-explanation", 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() }()
responseBody, err := readResponseBody(resp)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
var result ContributionHistoryExplanationResult
if err := json.Unmarshal(responseBody, &result); err != nil {
return nil, fmt.Errorf("failed to parse response (status %d, body: %s): %w",
resp.StatusCode, truncateBody(responseBody), err)
}
if resp.StatusCode >= 400 {
return &result, fmt.Errorf("git contribution history explanation failed with status %d", resp.StatusCode)
}
return &result, nil
}
// GitRebase rebases the worktree branch onto the specified base branch.
// It first fetches origin/<baseBranch>, then rebases onto it.
// repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces.
@@ -0,0 +1,55 @@
package client
import (
"context"
"encoding/json"
"net/http"
"testing"
)
func TestContributionHistoryClient(t *testing.T) {
localHead := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
remoteHead := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
srv, got := captureServer(t, jsonResponder(http.StatusOK, `{
"repo":"svc",
"branch":"feature/history",
"expected_local_head":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"expected_remote_head":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"kind":"local_rebase",
"reason":"matched_reflog",
"onto_head":"cccccccccccccccccccccccccccccccccccccccc",
"task_commit_count":5,
"published_commit_count":5,
"new_base_commit_count":29
}`))
result, err := newHTTPOnlyClient(srv.URL).GitContributionHistoryExplanation(
context.Background(), "feature/history", localHead, remoteHead, "svc")
if err != nil {
t.Fatalf("GitContributionHistoryExplanation: %v", err)
}
if got.Method != http.MethodPost || got.Path != "/api/v1/git/contribution/history-explanation" {
t.Fatalf("request = %s %s, want POST /api/v1/git/contribution/history-explanation", got.Method, got.Path)
}
if got.ContentType != "application/json" {
t.Fatalf("content-type = %q, want application/json", got.ContentType)
}
var body map[string]any
if err := json.Unmarshal(got.Body, &body); err != nil {
t.Fatalf("decode request body: %v", err)
}
for key, want := range map[string]any{
"repo": "svc",
"branch": "feature/history",
"expected_local_head": localHead,
"expected_remote_head": remoteHead,
} {
if body[key] != want {
t.Errorf("body[%q] = %#v, want %#v", key, body[key], want)
}
}
if result.Kind != "local_rebase" || result.Reason != "matched_reflog" ||
result.TaskCommitCount == nil || *result.TaskCommitCount != 5 {
t.Fatalf("result = %+v, want decoded explanation", result)
}
}
@@ -80,6 +80,15 @@ type GitContributionRequest struct {
Repo string `json:"repo,omitempty"`
}
// GitContributionHistoryExplanationRequest describes the selected local and
// published heads for a read-only history observation.
type GitContributionHistoryExplanationRequest struct {
Branch string `json:"branch"`
ExpectedLocalHead string `json:"expected_local_head"`
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"`
@@ -278,6 +287,43 @@ func (s *Server) handleGitUseContribution(c *gin.Context) {
})
}
func (s *Server) handleGitContributionHistoryExplanation(c *gin.Context) {
var req GitContributionHistoryExplanationRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, process.GitOperationResult{
Success: false, Operation: "contribution_history_explanation", Error: "invalid request: " + err.Error(),
})
return
}
for _, required := range []struct {
name string
value string
}{
{name: "branch", value: req.Branch},
{name: "expected_local_head", value: req.ExpectedLocalHead},
{name: "expected_remote_head", value: req.ExpectedRemoteHead},
} {
if required.value == "" {
c.JSON(http.StatusBadRequest, process.GitOperationResult{
Success: false, Operation: "contribution_history_explanation", Error: required.name + " is required",
})
return
}
}
gitOp := s.gitOpForRepo(c, "contribution_history_explanation", req.Repo)
if gitOp == nil {
return
}
result, err := gitOp.ExplainContributionHistory(
c.Request.Context(), req.Branch, req.ExpectedLocalHead, req.ExpectedRemoteHead)
if err != nil {
s.handleGitError(c, "contribution_history_explanation", err)
return
}
c.JSON(http.StatusOK, result)
}
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 {
@@ -0,0 +1,59 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/kandev/kandev/internal/agentctl/server/process"
)
func TestContributionHistoryAPI(t *testing.T) {
fixture := newGitAPIFixture(t)
localHead := fixture.head(t)
remoteHead := strings.TrimSpace(runGitAPI(t, fixture.repo, "rev-parse", "origin/main"))
rec := postGitAPI(t, fixture.server, "/api/v1/git/contribution/history-explanation", map[string]any{
"branch": fixture.branch(t),
"expected_local_head": localHead,
"expected_remote_head": remoteHead,
})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %s)", rec.Code, rec.Body.String())
}
var result process.ContributionHistoryExplanationResult
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatalf("decode response: %v", err)
}
if result.Branch != fixture.branch(t) || result.ExpectedLocalHead != localHead ||
result.ExpectedRemoteHead != remoteHead {
t.Fatalf("result identity = %+v, want request identity", result)
}
if result.Kind != "unexplained" || result.Reason != "no_matching_reflog" {
t.Fatalf("result = %+v, want neutral no-matching-reflog response", result)
}
}
func TestContributionHistoryAPIRejectsMissingFields(t *testing.T) {
fixture := newGitAPIFixture(t)
for _, field := range []string{"branch", "expected_local_head", "expected_remote_head"} {
t.Run(field, func(t *testing.T) {
body := map[string]any{
"branch": fixture.branch(t),
"expected_local_head": localHeadForAPI(t, fixture),
"expected_remote_head": strings.TrimSpace(runGitAPI(t, fixture.repo, "rev-parse", "origin/main")),
}
delete(body, field)
rec := postGitAPI(t, fixture.server, "/api/v1/git/contribution/history-explanation", body)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body %s)", rec.Code, rec.Body.String())
}
})
}
}
func localHeadForAPI(t *testing.T, fixture *gitAPIFixture) string {
t.Helper()
return fixture.head(t)
}
@@ -132,6 +132,7 @@ func TestGitHandlers_RejectMalformedBody(t *testing.T) {
{"/api/v1/git/push-preflight", "push_preflight"},
{"/api/v1/git/contribution/replace", "replace_remote_contribution"},
{"/api/v1/git/contribution/use", "use_remote_contribution"},
{"/api/v1/git/contribution/history-explanation", "contribution_history_explanation"},
{"/api/v1/git/rebase", "rebase"},
{"/api/v1/git/merge", "merge"},
{"/api/v1/git/abort", "abort"},
@@ -204,6 +205,7 @@ func TestGitHandlers_RejectMissingRequiredFields(t *testing.T) {
{"reset without sha", "/api/v1/git/reset", GitResetRequest{}, "reset", "commit_sha is required"},
{"replace without expected head", "/api/v1/git/contribution/replace", GitContributionRequest{}, "replace_remote_contribution", "expected_remote_head is required"},
{"use without expected head", "/api/v1/git/contribution/use", GitContributionRequest{}, "use_remote_contribution", "expected_remote_head is required"},
{"history explanation without branch", "/api/v1/git/contribution/history-explanation", GitContributionHistoryExplanationRequest{}, "contribution_history_explanation", "branch is required"},
{
"reset with unknown mode",
"/api/v1/git/reset",
@@ -215,6 +215,7 @@ func (s *Server) setupRoutes() {
api.POST("/git/push-preflight", s.handleGitPushPreflight)
api.POST("/git/contribution/replace", s.handleGitReplaceContribution)
api.POST("/git/contribution/use", s.handleGitUseContribution)
api.POST("/git/contribution/history-explanation", s.handleGitContributionHistoryExplanation)
api.POST("/git/rebase", s.handleGitRebase)
api.POST("/git/merge", s.handleGitMerge)
api.POST("/git/abort", s.handleGitAbort)
@@ -66,10 +66,11 @@ type GitOperationResult struct {
// GitOperator executes git operations in a workspace directory.
type GitOperator struct {
workDir string
logger *logger.Logger
workspaceTracker *WorkspaceTracker
environment func() []string
workDir string
logger *logger.Logger
workspaceTracker *WorkspaceTracker
environment func() []string
contributionHistoryCommandOverride func(context.Context, ...string) (string, error)
// repoName is the multi-repo subpath this operator runs in (e.g. "kandev").
// Empty for the workspace-root operator. Stamped on emitted commit
// notifications so the frontend can group commits per repo.
@@ -0,0 +1,459 @@
package process
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/kandev/kandev/internal/common/securityutil"
"github.com/kandev/kandev/internal/common/subproc"
"go.uber.org/zap"
)
const (
contributionHistoryObservationTimeout = 2 * time.Second
contributionHistoryReflogLimit = 200
contributionHistoryCommitLimit = 2000
contributionHistoryOutputLimit = 1 << 20
contributionHistoryKindLocalRebase = "local_rebase"
contributionHistoryKindUnexplained = "unexplained"
contributionHistoryReasonMatched = "matched_reflog"
contributionHistoryReasonNoMatch = "no_matching_reflog"
contributionHistoryReasonMissing = "missing_objects"
contributionHistoryReasonAmbiguous = "ambiguous"
contributionHistoryReasonStale = "stale"
contributionHistoryReasonLimit = "limit"
contributionHistoryReasonUnavailable = "unavailable"
)
var (
errContributionHistoryMissingObjects = errors.New("contribution history objects are unavailable")
errContributionHistoryAmbiguous = errors.New("contribution history is ambiguous")
errContributionHistoryLimit = errors.New("contribution history observation limit reached")
errContributionHistoryStale = errors.New("contribution history identity is stale")
)
// ContributionHistoryExplanationResult is a bounded, read-only explanation of
// a task branch and its published contribution head.
type ContributionHistoryExplanationResult struct {
Repo string `json:"repo,omitempty"`
Branch string `json:"branch"`
ExpectedLocalHead string `json:"expected_local_head"`
ExpectedRemoteHead string `json:"expected_remote_head"`
Kind string `json:"kind"`
Reason string `json:"reason"`
OntoHead string `json:"onto_head,omitempty"`
TaskCommitCount *int `json:"task_commit_count,omitempty"`
PublishedCommitCount *int `json:"published_commit_count,omitempty"`
NewBaseCommitCount *int `json:"new_base_commit_count,omitempty"`
}
type contributionHistoryReflogEntry struct {
sha string
subject string
}
type contributionHistoryCountResult struct {
count *int
limited bool
failed bool
}
// contributionHistoryOutputWriter captures command output without allowing a
// reflog or commit walk to grow the agentctl process without bound.
type contributionHistoryOutputWriter struct {
mu sync.Mutex
buffer bytes.Buffer
limit int
truncated bool
}
func newContributionHistoryOutputWriter(limit int) *contributionHistoryOutputWriter {
return &contributionHistoryOutputWriter{limit: limit}
}
func (w *contributionHistoryOutputWriter) Write(data []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
remaining := w.limit - w.buffer.Len()
if remaining > 0 {
keep := len(data)
if keep > remaining {
keep = remaining
}
_, _ = w.buffer.Write(data[:keep])
}
if len(data) > remaining {
w.truncated = true
}
return len(data), nil
}
func (w *contributionHistoryOutputWriter) String() string {
w.mu.Lock()
defer w.mu.Unlock()
return w.buffer.String()
}
func (w *contributionHistoryOutputWriter) Truncated() bool {
w.mu.Lock()
defer w.mu.Unlock()
return w.truncated
}
// ExplainContributionHistory observes local Git state for a selected
// contribution identity. It never fetches, rewrites, or publishes refs.
func (g *GitOperator) ExplainContributionHistory(
ctx context.Context,
branch, expectedLocalHead, expectedRemoteHead string,
) (*ContributionHistoryExplanationResult, error) {
result := newContributionHistoryResult(g.repoName, branch, expectedLocalHead, expectedRemoteHead)
if !validContributionHistoryRequest(branch, expectedLocalHead, expectedRemoteHead) {
return result, nil
}
observeCtx, cancel := context.WithTimeout(ctx, contributionHistoryObservationTimeout)
defer cancel()
if err := g.requireNoContributionHistoryRebase(observeCtx); err != nil {
result.Reason = contributionHistoryFailureReason(err, contributionHistoryReasonAmbiguous)
return result, nil
}
if err := g.requireContributionHistoryIdentity(observeCtx, branch, expectedLocalHead); err != nil {
result.Reason = contributionHistoryFailureReason(err, contributionHistoryReasonUnavailable)
return result, nil
}
if err := g.requireContributionHistoryObjects(observeCtx, expectedLocalHead, expectedRemoteHead); err != nil {
result.Reason = contributionHistoryFailureReason(err, contributionHistoryReasonMissing)
return result, nil
}
ontoHead, err := g.findContributionHistoryRebase(observeCtx, branch, expectedLocalHead, expectedRemoteHead)
if err != nil {
result.Reason = contributionHistoryFailureReason(err, contributionHistoryReasonNoMatch)
return result, nil
}
if err := g.requireContributionHistoryObject(observeCtx, ontoHead); err != nil {
result.Reason = contributionHistoryFailureReason(err, contributionHistoryReasonMissing)
return result, nil
}
ancestor, err := g.contributionHistoryIsAncestor(observeCtx, ontoHead, expectedLocalHead)
if err != nil {
result.Reason = contributionHistoryFailureReason(err, contributionHistoryReasonUnavailable)
return result, nil
}
if !ancestor {
result.Reason = contributionHistoryReasonAmbiguous
return result, nil
}
result.Kind = contributionHistoryKindLocalRebase
result.Reason = contributionHistoryReasonMatched
result.OntoHead = ontoHead
if limited, failed := g.populateContributionHistoryCounts(observeCtx, result); failed {
result.Kind = contributionHistoryKindUnexplained
result.Reason = contributionHistoryReasonUnavailable
result.OntoHead = ""
} else if limited {
result.Reason = contributionHistoryReasonLimit
}
if err := g.requireContributionHistoryIdentity(observeCtx, branch, expectedLocalHead); err != nil {
result = newContributionHistoryResult(g.repoName, branch, expectedLocalHead, expectedRemoteHead)
result.Reason = contributionHistoryFailureReason(err, contributionHistoryReasonStale)
return result, nil
}
return result, nil
}
func newContributionHistoryResult(repo, branch, localHead, remoteHead string) *ContributionHistoryExplanationResult {
return &ContributionHistoryExplanationResult{
Repo: repo,
Branch: branch,
ExpectedLocalHead: localHead,
ExpectedRemoteHead: remoteHead,
Kind: contributionHistoryKindUnexplained,
Reason: contributionHistoryReasonUnavailable,
}
}
func validContributionHistoryRequest(branch, localHead, remoteHead string) bool {
return securityutil.IsValidBranchName(branch) && !securityutil.IsGitSymbolicRef(branch) &&
isFullCommitSHA(localHead) && isFullCommitSHA(remoteHead)
}
func contributionHistoryFailureReason(err error, fallback string) string {
switch {
case errors.Is(err, errContributionHistoryLimit):
return contributionHistoryReasonLimit
case errors.Is(err, errContributionHistoryMissingObjects):
return contributionHistoryReasonMissing
case errors.Is(err, errContributionHistoryAmbiguous):
return contributionHistoryReasonAmbiguous
case errors.Is(err, errContributionHistoryStale):
return contributionHistoryReasonStale
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
return contributionHistoryReasonUnavailable
default:
return fallback
}
}
func (g *GitOperator) requireContributionHistoryIdentity(ctx context.Context, branch, expectedHead string) error {
currentBranch, err := g.contributionHistoryValue(ctx, "rev-parse", "--abbrev-ref", "HEAD")
if err != nil {
return err
}
currentHead, err := g.contributionHistoryValue(ctx, "rev-parse", "HEAD")
if err != nil {
return err
}
if currentBranch != branch || !strings.EqualFold(currentHead, expectedHead) {
return fmt.Errorf("%w: selected branch or head changed", errContributionHistoryStale)
}
return nil
}
func (g *GitOperator) requireContributionHistoryObjects(ctx context.Context, heads ...string) error {
for _, head := range heads {
if err := g.requireContributionHistoryObject(ctx, head); err != nil {
return err
}
}
return nil
}
func (g *GitOperator) requireContributionHistoryObject(ctx context.Context, head string) error {
resolved, err := g.contributionHistoryValue(ctx, "rev-parse", "--verify", head+"^{commit}")
if err != nil {
return fmt.Errorf("%w: %v", errContributionHistoryMissingObjects, err)
}
if !strings.EqualFold(resolved, head) {
return fmt.Errorf("%w: resolved head differs", errContributionHistoryMissingObjects)
}
return nil
}
func (g *GitOperator) requireNoContributionHistoryRebase(ctx context.Context) error {
for _, state := range []string{"rebase-merge", "rebase-apply"} {
path, err := g.contributionHistoryValue(ctx, "rev-parse", "--git-path", state)
if err != nil {
return err
}
if !filepath.IsAbs(path) {
path = filepath.Join(g.workDir, path)
}
if _, err := os.Stat(path); err == nil {
return errContributionHistoryAmbiguous
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
}
return nil
}
func (g *GitOperator) findContributionHistoryRebase(
ctx context.Context, branch, localHead, remoteHead string,
) (string, error) {
branchOutput, err := g.contributionHistoryValue(ctx, "reflog", "show",
"--format=%H%x1f%gs", fmt.Sprintf("-n%d", contributionHistoryReflogLimit), branch)
if err != nil {
return "", err
}
branchEntries, err := parseContributionHistoryReflog(branchOutput)
if err != nil {
return "", err
}
ontoHead, err := matchContributionHistoryBranchReflog(branchEntries, branch, localHead, remoteHead)
if err != nil {
return "", err
}
headOutput, err := g.contributionHistoryValue(ctx, "reflog", "show",
"--format=%H%x1f%gs", fmt.Sprintf("-n%d", contributionHistoryReflogLimit), "HEAD")
if err != nil {
return "", err
}
headEntries, err := parseContributionHistoryReflog(headOutput)
if err != nil {
return "", err
}
if err := matchContributionHistoryHeadReflog(headEntries, branch, localHead, ontoHead); err != nil {
return "", err
}
return ontoHead, nil
}
func parseContributionHistoryReflog(output string) ([]contributionHistoryReflogEntry, error) {
var entries []contributionHistoryReflogEntry
for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
sha, subject, ok := strings.Cut(line, "\x1f")
if !ok || !isFullCommitSHA(sha) {
return nil, errContributionHistoryAmbiguous
}
entries = append(entries, contributionHistoryReflogEntry{sha: sha, subject: strings.TrimSpace(subject)})
if len(entries) > contributionHistoryReflogLimit {
return nil, errContributionHistoryLimit
}
}
return entries, nil
}
func matchContributionHistoryBranchReflog(
entries []contributionHistoryReflogEntry, branch, localHead, remoteHead string,
) (string, error) {
if len(entries) < 2 || !strings.EqualFold(entries[0].sha, localHead) ||
!strings.EqualFold(entries[1].sha, remoteHead) {
return "", errors.New("no matching branch reflog")
}
prefix := "rebase (finish): refs/heads/" + branch + " onto "
if !strings.HasPrefix(entries[0].subject, prefix) {
return "", errors.New("no matching branch reflog")
}
ontoHead := strings.TrimSpace(strings.TrimPrefix(entries[0].subject, prefix))
if !isFullCommitSHA(ontoHead) {
return "", errContributionHistoryAmbiguous
}
return ontoHead, nil
}
func matchContributionHistoryHeadReflog(
entries []contributionHistoryReflogEntry, branch, localHead, ontoHead string,
) error {
if len(entries) < 3 || !strings.EqualFold(entries[0].sha, localHead) {
return errors.New("no matching HEAD reflog")
}
if entries[0].subject != "rebase (finish): returning to refs/heads/"+branch {
return errors.New("no matching HEAD reflog")
}
startPrefix := "rebase (start):"
replayCount := 0
for _, entry := range entries[1:] {
switch {
case strings.HasPrefix(entry.subject, "rebase (pick):"),
strings.HasPrefix(entry.subject, "rebase (continue):"),
strings.HasPrefix(entry.subject, "rebase (reword):"),
strings.HasPrefix(entry.subject, "rebase (edit):"):
replayCount++
case strings.HasPrefix(entry.subject, startPrefix):
if replayCount == 0 || !strings.EqualFold(entry.sha, ontoHead) {
return errContributionHistoryAmbiguous
}
return nil
default:
return errContributionHistoryAmbiguous
}
}
return errContributionHistoryAmbiguous
}
func (g *GitOperator) contributionHistoryIsAncestor(ctx context.Context, ancestor, descendant string) (bool, error) {
_, err := g.contributionHistoryCommand(ctx, "merge-base", "--is-ancestor", ancestor, descendant)
if err == nil {
return true, nil
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
return false, nil
}
return false, err
}
func (g *GitOperator) populateContributionHistoryCounts(
ctx context.Context, result *ContributionHistoryExplanationResult,
) (limited, failed bool) {
base, err := g.contributionHistoryMergeBase(ctx, result.ExpectedRemoteHead, result.OntoHead)
if err != nil {
return errors.Is(err, errContributionHistoryLimit), errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)
}
task := g.contributionHistoryCount(ctx, result.OntoHead, result.ExpectedLocalHead)
published := g.contributionHistoryCount(ctx, base, result.ExpectedRemoteHead)
newBase := g.contributionHistoryCount(ctx, base, result.OntoHead)
result.TaskCommitCount = task.count
result.PublishedCommitCount = published.count
result.NewBaseCommitCount = newBase.count
return task.limited || published.limited || newBase.limited,
task.failed || published.failed || newBase.failed
}
func (g *GitOperator) contributionHistoryMergeBase(ctx context.Context, left, right string) (string, error) {
output, err := g.contributionHistoryValue(ctx, "merge-base", "--all", left, right)
if err != nil {
return "", err
}
lines := strings.Fields(output)
if len(lines) != 1 || !isFullCommitSHA(lines[0]) {
return "", errContributionHistoryAmbiguous
}
return lines[0], nil
}
func (g *GitOperator) contributionHistoryCount(ctx context.Context, from, to string) contributionHistoryCountResult {
rangeRef := from + ".." + to
merges, err := g.contributionHistoryValue(ctx, "rev-list", "--merges", "-n1", rangeRef)
if err != nil {
return contributionHistoryCountResult{failed: errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)}
}
if strings.TrimSpace(merges) != "" {
return contributionHistoryCountResult{}
}
output, err := g.contributionHistoryValue(ctx, "rev-list", "--count",
fmt.Sprintf("-n%d", contributionHistoryCommitLimit+1), rangeRef)
if err != nil {
return contributionHistoryCountResult{failed: errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)}
}
count, err := strconv.Atoi(strings.TrimSpace(output))
if err != nil {
return contributionHistoryCountResult{failed: true}
}
if count > contributionHistoryCommitLimit {
return contributionHistoryCountResult{limited: true}
}
return contributionHistoryCountResult{count: &count}
}
func (g *GitOperator) contributionHistoryValue(ctx context.Context, args ...string) (string, error) {
output, err := g.contributionHistoryCommand(ctx, args...)
return strings.TrimSpace(output), err
}
func (g *GitOperator) contributionHistoryCommand(ctx context.Context, args ...string) (string, error) {
if err := validateGitCommandArgs(args); err != nil {
return "", err
}
if g.contributionHistoryCommandOverride != nil {
return g.contributionHistoryCommandOverride(ctx, args...)
}
cmd := subproc.NewGitCommand(ctx, args...)
cmd.Dir = g.workDir
cmd.Env = filterGitEnv(g.environmentValues())
output := newContributionHistoryOutputWriter(contributionHistoryOutputLimit)
cmd.Stdout = output
cmd.Stderr = output
g.logger.Debug("executing bounded contribution history command", zap.Strings("args", args))
err := subproc.RunGitClass(ctx, subproc.GitInteractive, cmd)
if ctxErr := ctx.Err(); ctxErr != nil {
return output.String(), ctxErr
}
if output.Truncated() {
return output.String(), errContributionHistoryLimit
}
if err != nil {
return output.String(), fmt.Errorf("git command failed: %w: %s", err, strings.TrimSpace(output.String()))
}
return output.String(), nil
}
@@ -0,0 +1,384 @@
package process
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
"time"
)
func TestContributionHistoryExpiredReflogFallsBackToNeutral(t *testing.T) {
_, linkedDir, localHead, remoteHead, _, cleanup := seedContributionHistoryRepo(t)
defer cleanup()
runGit(t, linkedDir, "reflog", "expire", "--expire=now", "--all")
result, err := NewGitOperator(linkedDir, newTestLogger(t), nil).ExplainContributionHistory(
context.Background(), "feature/history", localHead, remoteHead)
if err != nil {
t.Fatalf("ExplainContributionHistory returned error: %v", err)
}
assertContributionHistoryNeutral(t, result, contributionHistoryReasonNoMatch)
}
func TestContributionHistoryMissingObjectsFallsBackToNeutral(t *testing.T) {
repoDir, cleanup := setupTestRepo(t)
defer cleanup()
localHead := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD"))
missingHead := strings.Repeat("0", 40)
result, err := NewGitOperator(repoDir, newTestLogger(t), nil).ExplainContributionHistory(
context.Background(), "main", localHead, missingHead)
if err != nil {
t.Fatalf("ExplainContributionHistory returned error: %v", err)
}
assertContributionHistoryNeutral(t, result, contributionHistoryReasonMissing)
}
func TestContributionHistoryPostRebaseCommitFallsBackToNeutral(t *testing.T) {
_, linkedDir, _, remoteHead, _, cleanup := seedContributionHistoryRepo(t)
defer cleanup()
writeFile(t, linkedDir, "after-rebase.txt", "post-rebase change\n")
runGit(t, linkedDir, "add", "after-rebase.txt")
runGit(t, linkedDir, "commit", "-m", "post-rebase commit")
localHead := strings.TrimSpace(runGit(t, linkedDir, "rev-parse", "HEAD"))
result, err := NewGitOperator(linkedDir, newTestLogger(t), nil).ExplainContributionHistory(
context.Background(), "feature/history", localHead, remoteHead)
if err != nil {
t.Fatalf("ExplainContributionHistory returned error: %v", err)
}
assertContributionHistoryNeutral(t, result, contributionHistoryReasonNoMatch)
}
func TestContributionHistoryInProgressRebaseIsAmbiguous(t *testing.T) {
_, linkedDir, currentHead, remoteHead, _, cleanup := seedContributionHistoryConflictRepo(t)
defer cleanup()
result, err := NewGitOperator(linkedDir, newTestLogger(t), nil).ExplainContributionHistory(
context.Background(), "feature/history", currentHead, remoteHead)
if err != nil {
t.Fatalf("ExplainContributionHistory returned error: %v", err)
}
assertContributionHistoryNeutral(t, result, contributionHistoryReasonAmbiguous)
runGit(t, linkedDir, "rebase", "--abort")
}
func TestContributionHistoryConflictResolutionKeepsRebaseEvidence(t *testing.T) {
_, linkedDir, _, remoteHead, ontoHead, cleanup := seedContributionHistoryConflictRepo(t)
defer cleanup()
writeFile(t, linkedDir, "conflict.txt", "resolved after rebase\n")
runGit(t, linkedDir, "add", "conflict.txt")
runGit(t, linkedDir, "-c", "core.editor=true", "rebase", "--continue")
localHead := strings.TrimSpace(runGit(t, linkedDir, "rev-parse", "HEAD"))
result, err := NewGitOperator(linkedDir, newTestLogger(t), nil).ExplainContributionHistory(
context.Background(), "feature/history", localHead, remoteHead)
if err != nil {
t.Fatalf("ExplainContributionHistory returned error: %v", err)
}
if result.Kind != contributionHistoryKindLocalRebase || result.Reason != contributionHistoryReasonMatched {
t.Fatalf("result = %+v, want matched conflict-resolved rebase", result)
}
if result.OntoHead != ontoHead {
t.Fatalf("onto_head = %q, want %q", result.OntoHead, ontoHead)
}
if result.TaskCommitCount == nil || *result.TaskCommitCount != 1 {
t.Fatalf("task_commit_count = %v, want 1", result.TaskCommitCount)
}
if result.PublishedCommitCount == nil || *result.PublishedCommitCount != 1 {
t.Fatalf("published_commit_count = %v, want 1", result.PublishedCommitCount)
}
if result.NewBaseCommitCount == nil || *result.NewBaseCommitCount != 1 {
t.Fatalf("new_base_commit_count = %v, want 1", result.NewBaseCommitCount)
}
}
func TestContributionHistoryMergeRangeOmitsPublishedCount(t *testing.T) {
_, linkedDir, localHead, remoteHead, ontoHead, cleanup := seedContributionHistoryMergeRepo(t)
defer cleanup()
result, err := NewGitOperator(linkedDir, newTestLogger(t), nil).ExplainContributionHistory(
context.Background(), "feature/history", localHead, remoteHead)
if err != nil {
t.Fatalf("ExplainContributionHistory returned error: %v", err)
}
if result.Kind != contributionHistoryKindLocalRebase || result.Reason != contributionHistoryReasonMatched {
t.Fatalf("result = %+v, want matched rebase with a merge range", result)
}
if result.OntoHead != ontoHead {
t.Fatalf("onto_head = %q, want %q", result.OntoHead, ontoHead)
}
if result.PublishedCommitCount != nil {
t.Fatalf("published_commit_count = %v, want omitted for a merge range", result.PublishedCommitCount)
}
}
func TestContributionHistoryReflogObservationUsesTwoHundredEntryBound(t *testing.T) {
repoDir, cleanup := setupTestRepo(t)
defer cleanup()
localHead := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD"))
argsFile := filepath.Join(t.TempDir(), "git-args")
operator := NewGitOperator(repoDir, newTestLogger(t), nil)
operator.contributionHistoryCommandOverride = contributionHistoryGitCommandOverride(t, repoDir,
func(args []string) (string, error) {
if err := os.WriteFile(argsFile, []byte(strings.Join(args, "\x1f")), 0o600); err != nil {
return "", err
}
return "", errors.New("synthetic reflog failure")
})
result, err := operator.ExplainContributionHistory(
context.Background(), "main", localHead, localHead)
if err != nil {
t.Fatalf("ExplainContributionHistory returned error: %v", err)
}
assertContributionHistoryNeutral(t, result, contributionHistoryReasonNoMatch)
args, err := os.ReadFile(argsFile)
if err != nil {
t.Fatalf("read captured git arguments: %v", err)
}
if !strings.Contains(string(args), "-n200") {
t.Fatalf("reflog command arguments = %q, want -n200", args)
}
}
func TestContributionHistoryReflogOverflowFallsBackToBoundedNeutral(t *testing.T) {
repoDir, cleanup := setupTestRepo(t)
defer cleanup()
head := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD"))
operator := NewGitOperator(repoDir, newTestLogger(t), nil)
operator.contributionHistoryCommandOverride = contributionHistoryGitCommandOverride(t, repoDir,
func(args []string) (string, error) {
return strings.Repeat(fmt.Sprintf("%s\x1fnoise\n", head), contributionHistoryReflogLimit+1), nil
})
result, err := operator.ExplainContributionHistory(
context.Background(), "main", head, head)
if err != nil {
t.Fatalf("ExplainContributionHistory returned error: %v", err)
}
assertContributionHistoryNeutral(t, result, contributionHistoryReasonLimit)
}
func TestContributionHistoryCommitCountBoundary(t *testing.T) {
repoDir, cleanup := setupTestRepo(t)
defer cleanup()
base := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD"))
runGit(t, repoDir, "checkout", "-b", "feature/count-limit")
createContributionHistoryCommitChain(t, repoDir, "feature/count-limit", base, contributionHistoryCommitLimit+1)
head := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD"))
operator := NewGitOperator(repoDir, newTestLogger(t), nil)
limited := operator.contributionHistoryCount(context.Background(), base, head)
if !limited.limited || limited.count != nil {
t.Fatalf("count at limit = %+v, want limited with no count", limited)
}
underLimitHead := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD~1"))
underLimit := operator.contributionHistoryCount(context.Background(), base, underLimitHead)
if underLimit.limited || underLimit.count == nil || *underLimit.count != contributionHistoryCommitLimit {
t.Fatalf("count below limit = %+v, want exact count %d", underLimit, contributionHistoryCommitLimit)
}
}
func createContributionHistoryCommitChain(t *testing.T, repoDir, branch, base string, count int) {
t.Helper()
var input strings.Builder
for index := 0; index < count; index++ {
input.WriteString("commit refs/heads/")
input.WriteString(branch)
input.WriteByte('\n')
input.WriteString("mark :")
input.WriteString(strconv.Itoa(index + 1))
input.WriteByte('\n')
input.WriteString("author Kandev Test <test@example.com> 1700000000 +0000\n")
input.WriteString("committer Kandev Test <test@example.com> 1700000000 +0000\n")
message := fmt.Sprintf("count commit %d", index)
input.WriteString("data ")
input.WriteString(strconv.Itoa(len(message)))
input.WriteByte('\n')
input.WriteString(message)
input.WriteByte('\n')
if index == 0 {
input.WriteString("from ")
input.WriteString(base)
input.WriteByte('\n')
} else {
input.WriteString("from :")
input.WriteString(strconv.Itoa(index))
input.WriteByte('\n')
}
}
cmd := exec.Command("git", "-C", repoDir, "fast-import")
cmd.Env = filterTestGitEnv(os.Environ())
cmd.Stdin = strings.NewReader(input.String())
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git fast-import failed: %v\nOutput: %s", err, output)
}
}
func TestContributionHistoryRunningCommandStopsAtObservationDeadline(t *testing.T) {
repoDir, cleanup := setupTestRepo(t)
defer cleanup()
head := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD"))
installContributionHistoryGitTimeoutShim(t)
started := time.Now()
result, err := NewGitOperator(repoDir, newTestLogger(t), nil).ExplainContributionHistory(
context.Background(), "main", head, head)
if err != nil {
t.Fatalf("ExplainContributionHistory returned error: %v", err)
}
if elapsed := time.Since(started); elapsed > 4*time.Second {
t.Fatalf("observation took %s, want it to stop near the 2-second deadline", elapsed)
}
assertContributionHistoryNeutral(t, result, contributionHistoryReasonUnavailable)
}
func contributionHistoryGitCommandOverride(
t *testing.T,
repoDir string,
reflogOverride func([]string) (string, error),
) func(context.Context, ...string) (string, error) {
t.Helper()
realGit, err := exec.LookPath("git")
if err != nil {
t.Fatalf("exec.LookPath(git) returned error: %v", err)
}
return func(ctx context.Context, args ...string) (string, error) {
if len(args) >= 2 && args[0] == "reflog" && args[1] == "show" {
return reflogOverride(args)
}
cmd := exec.CommandContext(ctx, realGit, args...)
cmd.Dir = repoDir
cmd.Env = filterTestGitEnv(os.Environ())
output, err := cmd.CombinedOutput()
return string(output), err
}
}
func installContributionHistoryGitTimeoutShim(t *testing.T) {
t.Helper()
realGit, err := exec.LookPath("git")
if err != nil {
t.Fatalf("exec.LookPath(git) returned error: %v", err)
}
shimDir := t.TempDir()
shimName := "git"
if runtime.GOOS == "windows" {
shimName += ".exe"
}
shimPath := filepath.Join(shimDir, shimName)
executable, err := os.Executable()
if err != nil {
t.Fatalf("find test executable: %v", err)
}
contents, err := os.ReadFile(executable)
if err != nil {
t.Fatalf("read test executable: %v", err)
}
if err := os.WriteFile(shimPath, contents, 0o755); err != nil {
t.Fatalf("write Git shim: %v", err)
}
if runtime.GOOS != "windows" {
if err := os.Chmod(shimPath, 0o755); err != nil {
t.Fatalf("make Git shim executable: %v", err)
}
}
t.Setenv("PATH", shimDir+string(os.PathListSeparator)+os.Getenv("PATH"))
t.Setenv(contributionHistoryGitShimModeEnv, contributionHistoryGitShimTimeout)
t.Setenv(contributionHistoryGitShimRealGitEnv, realGit)
}
func assertContributionHistoryNeutral(t *testing.T, result *ContributionHistoryExplanationResult, reason string) {
t.Helper()
if result.Kind != contributionHistoryKindUnexplained || result.Reason != reason {
t.Fatalf("result = %+v, want unexplained/%s", result, reason)
}
if result.OntoHead != "" || result.TaskCommitCount != nil || result.PublishedCommitCount != nil || result.NewBaseCommitCount != nil {
t.Fatalf("result = %+v, want no inferred rebase details", result)
}
}
func seedContributionHistoryConflictRepo(t *testing.T) (repoDir, linkedDir, currentHead, remoteHead, ontoHead string, cleanup func()) {
t.Helper()
repoDir, cleanup = setupTestRepo(t)
runGit(t, repoDir, "checkout", "-b", "feature/history")
writeFile(t, repoDir, "conflict.txt", "feature version\n")
runGit(t, repoDir, "add", "conflict.txt")
runGit(t, repoDir, "commit", "-m", "feature conflict commit")
runGit(t, repoDir, "push", "-u", "origin", "feature/history")
remoteHead = strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD"))
runGit(t, repoDir, "checkout", "main")
writeFile(t, repoDir, "conflict.txt", "base version\n")
runGit(t, repoDir, "add", "conflict.txt")
runGit(t, repoDir, "commit", "-m", "base conflict commit")
runGit(t, repoDir, "push", "origin", "main")
ontoHead = strings.TrimSpace(runGit(t, repoDir, "rev-parse", "origin/main"))
linkedDir = filepath.Join(t.TempDir(), "linked-feature")
runGit(t, repoDir, "worktree", "add", linkedDir, "feature/history")
runGitExpectFailure(t, linkedDir, "rebase", "origin/main")
currentHead = strings.TrimSpace(runGit(t, linkedDir, "rev-parse", "HEAD"))
return repoDir, linkedDir, currentHead, remoteHead, ontoHead, cleanup
}
func seedContributionHistoryMergeRepo(t *testing.T) (repoDir, linkedDir, localHead, remoteHead, ontoHead string, cleanup func()) {
t.Helper()
repoDir, cleanup = setupTestRepo(t)
runGit(t, repoDir, "checkout", "-b", "feature/history")
writeFile(t, repoDir, "feature-one.txt", "feature one\n")
runGit(t, repoDir, "add", "feature-one.txt")
runGit(t, repoDir, "commit", "-m", "feature one")
runGit(t, repoDir, "checkout", "main")
writeFile(t, repoDir, "base-one.txt", "base one\n")
runGit(t, repoDir, "add", "base-one.txt")
runGit(t, repoDir, "commit", "-m", "base one")
runGit(t, repoDir, "push", "origin", "main")
runGit(t, repoDir, "checkout", "feature/history")
writeFile(t, repoDir, "feature-two.txt", "feature two\n")
runGit(t, repoDir, "add", "feature-two.txt")
runGit(t, repoDir, "commit", "-m", "feature two")
runGit(t, repoDir, "merge", "--no-ff", "main", "-m", "merge main into feature")
runGit(t, repoDir, "push", "-u", "origin", "feature/history")
remoteHead = strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD"))
runGit(t, repoDir, "checkout", "main")
writeFile(t, repoDir, "base-two.txt", "base two\n")
runGit(t, repoDir, "add", "base-two.txt")
runGit(t, repoDir, "commit", "-m", "base two")
runGit(t, repoDir, "push", "origin", "main")
ontoHead = strings.TrimSpace(runGit(t, repoDir, "rev-parse", "origin/main"))
linkedDir = filepath.Join(t.TempDir(), "linked-feature")
runGit(t, repoDir, "worktree", "add", linkedDir, "feature/history")
runGit(t, linkedDir, "rebase", "origin/main")
localHead = strings.TrimSpace(runGit(t, linkedDir, "rev-parse", "HEAD"))
return repoDir, linkedDir, localHead, remoteHead, ontoHead, cleanup
}
func runGitExpectFailure(t *testing.T, dir string, args ...string) string {
t.Helper()
fullArgs := append([]string{"-C", dir, "-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false"}, args...)
cmd := exec.Command("git", fullArgs...)
cmd.Env = filterTestGitEnv(os.Environ())
out, err := cmd.CombinedOutput()
if err == nil {
t.Fatalf("git %v succeeded, want failure", args)
}
return string(out)
}
@@ -0,0 +1,170 @@
package process
import (
"context"
"fmt"
"path/filepath"
"strings"
"testing"
"time"
)
func TestContributionHistoryLocalRebase(t *testing.T) {
repoDir, linkedDir, localHead, remoteHead, onto, cleanup := seedContributionHistoryRepo(t)
defer cleanup()
operator := NewGitOperator(linkedDir, newTestLogger(t), nil)
result, err := operator.ExplainContributionHistory(
context.Background(), "feature/history", localHead, remoteHead)
if err != nil {
t.Fatalf("ExplainContributionHistory returned error: %v", err)
}
if result.Kind != "local_rebase" || result.Reason != "matched_reflog" {
t.Fatalf("result = %+v, want a matched local rebase", result)
}
if result.Branch != "feature/history" || result.ExpectedLocalHead != localHead ||
result.ExpectedRemoteHead != remoteHead {
t.Fatalf("result identity = %+v, want branch and both requested heads", result)
}
if result.OntoHead != onto {
t.Fatalf("onto_head = %q, want %q", result.OntoHead, onto)
}
if result.TaskCommitCount == nil || *result.TaskCommitCount != 5 {
t.Fatalf("task_commit_count = %v, want 5", result.TaskCommitCount)
}
if result.PublishedCommitCount == nil || *result.PublishedCommitCount != 5 {
t.Fatalf("published_commit_count = %v, want 5", result.PublishedCommitCount)
}
if result.NewBaseCommitCount == nil || *result.NewBaseCommitCount != 29 {
t.Fatalf("new_base_commit_count = %v, want 29", result.NewBaseCommitCount)
}
if got := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "--abbrev-ref", "HEAD")); got != "main" {
t.Fatalf("fixture root branch = %q, want main", got)
}
}
func TestContributionHistoryUnknown(t *testing.T) {
repoDir, cleanup := setupTestRepo(t)
defer cleanup()
runGit(t, repoDir, "checkout", "-b", "feature/history")
writeFile(t, repoDir, "feature.txt", "feature\n")
runGit(t, repoDir, "add", "feature.txt")
runGit(t, repoDir, "commit", "-m", "feature commit")
localHead := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD"))
remoteHead := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "origin/main"))
operator := NewGitOperator(repoDir, newTestLogger(t), nil)
result, err := operator.ExplainContributionHistory(
context.Background(), "feature/history", localHead, remoteHead)
if err != nil {
t.Fatalf("ExplainContributionHistory returned error: %v", err)
}
if result.Kind != "unexplained" || result.Reason != "no_matching_reflog" {
t.Fatalf("result = %+v, want a neutral no-matching-reflog result", result)
}
if result.OntoHead != "" || result.TaskCommitCount != nil ||
result.PublishedCommitCount != nil || result.NewBaseCommitCount != nil {
t.Fatalf("result = %+v, want no inferred rebase details", result)
}
stale, err := operator.ExplainContributionHistory(
context.Background(), "feature/history", strings.Repeat("a", 40), remoteHead)
if err != nil {
t.Fatalf("stale ExplainContributionHistory returned error: %v", err)
}
if stale.Kind != "unexplained" || stale.Reason != "stale" {
t.Fatalf("stale result = %+v, want a stale neutral result", stale)
}
}
func TestContributionHistoryCounts(t *testing.T) {
_, linkedDir, localHead, remoteHead, _, cleanup := seedContributionHistoryRepo(t)
defer cleanup()
operator := NewGitOperator(linkedDir, newTestLogger(t), nil)
result, err := operator.ExplainContributionHistory(
context.Background(), "feature/history", localHead, remoteHead)
if err != nil {
t.Fatalf("ExplainContributionHistory returned error: %v", err)
}
for name, value := range map[string]*int{
"task": result.TaskCommitCount,
"published": result.PublishedCommitCount,
"new base": result.NewBaseCommitCount,
} {
if value == nil {
t.Errorf("%s count is nil, want an exact count", name)
}
}
}
func TestContributionHistoryBounds(t *testing.T) {
writer := newContributionHistoryOutputWriter(8)
written, err := writer.Write([]byte("123456789"))
if err != nil {
t.Fatalf("Write returned error: %v", err)
}
if written != 9 {
t.Fatalf("Write returned %d, want the complete input length", written)
}
if got := writer.String(); got != "12345678" {
t.Fatalf("captured output = %q, want the configured bound", got)
}
if !writer.Truncated() {
t.Fatal("Truncated = false, want true after exceeding the bound")
}
}
func TestContributionHistoryCancellation(t *testing.T) {
repoDir, cleanup := setupTestRepo(t)
defer cleanup()
branch := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "--abbrev-ref", "HEAD"))
head := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD"))
ctx, cancel := context.WithCancel(context.Background())
cancel()
started := time.Now()
result, err := NewGitOperator(repoDir, newTestLogger(t), nil).ExplainContributionHistory(
ctx, branch, head, head)
if err != nil {
t.Fatalf("cancelled ExplainContributionHistory returned error: %v", err)
}
if elapsed := time.Since(started); elapsed > time.Second {
t.Fatalf("cancelled observation took %s, want prompt return", elapsed)
}
if result.Kind != "unexplained" || result.Reason != "unavailable" {
t.Fatalf("result = %+v, want an unavailable neutral result", result)
}
}
func seedContributionHistoryRepo(t *testing.T) (repoDir, linkedDir, localHead, remoteHead, onto string, cleanup func()) {
t.Helper()
repoDir, cleanup = setupTestRepo(t)
runGit(t, repoDir, "checkout", "-b", "feature/history")
for i := 1; i <= 5; i++ {
writeFile(t, repoDir, fmt.Sprintf("feature-commit-%d.txt", i), "feature\n")
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "feature commit")
}
runGit(t, repoDir, "push", "-u", "origin", "feature/history")
remoteHead = strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD"))
runGit(t, repoDir, "checkout", "main")
for i := 1; i <= 29; i++ {
writeFile(t, repoDir, fmt.Sprintf("base-commit-%d.txt", i), "base\n")
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "base commit")
}
runGit(t, repoDir, "push", "origin", "main")
onto = strings.TrimSpace(runGit(t, repoDir, "rev-parse", "origin/main"))
linkedDir = filepath.Join(t.TempDir(), "linked-feature")
runGit(t, repoDir, "worktree", "add", linkedDir, "feature/history")
runGit(t, linkedDir, "rebase", "origin/main")
localHead = strings.TrimSpace(runGit(t, linkedDir, "rev-parse", "HEAD"))
return repoDir, linkedDir, localHead, remoteHead, onto, cleanup
}
@@ -26,6 +26,9 @@ import (
const kandevTestFixtureEnv = "KANDEV_TEST_FIXTURE"
const gitFetchRaceRealGitEnv = "KANDEV_GIT_FETCH_RACE_REAL_GIT"
const gitFetchRaceWorktreeEnv = "KANDEV_GIT_FETCH_RACE_WORKTREE"
const contributionHistoryGitShimModeEnv = "KANDEV_TEST_CONTRIBUTION_HISTORY_GIT_SHIM_MODE"
const contributionHistoryGitShimRealGitEnv = "KANDEV_TEST_CONTRIBUTION_HISTORY_GIT_SHIM_REAL_GIT"
const contributionHistoryGitShimTimeout = "reflog-timeout"
const legacyGitLabHostEnv = "GITLAB_HOST"
// TestMain branches into fixture-binary mode when the activation env var
@@ -37,6 +40,10 @@ func TestMain(m *testing.M) {
runFixture(spec)
return
}
if mode := os.Getenv(contributionHistoryGitShimModeEnv); mode != "" {
runContributionHistoryGitShim(mode)
return
}
// Tests build GitOperator with a nil environment (NewGitOperator(dir, log,
// nil)), which falls back to os.Environ() (environmentValues in git.go).
// An inherited KANDEV_GITLAB_HOST / GITLAB_HOST / GITLAB_TOKEN then leaks
@@ -248,6 +255,32 @@ func runGitFetchRaceFixture() {
os.Exit(1)
}
func runContributionHistoryGitShim(mode string) {
args := os.Args[1:]
if len(args) >= 2 && args[0] == "reflog" && args[1] == "show" && mode == contributionHistoryGitShimTimeout {
time.Sleep(time.Hour)
os.Exit(0)
}
realGit := os.Getenv(contributionHistoryGitShimRealGitEnv)
if realGit == "" {
fmt.Fprintln(os.Stderr, "contribution history shim: missing real Git path")
os.Exit(2)
}
cmd := exec.Command(realGit, args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err == nil {
os.Exit(0)
} else if exitErr, ok := err.(*exec.ExitError); ok {
os.Exit(exitErr.ExitCode())
} else {
fmt.Fprintf(os.Stderr, "contribution history shim: run Git: %v\n", err)
os.Exit(1)
}
}
// fixtureExec returns the (Command argv, Env) pair tests pass to runners that
// take a Command []string and an Env map (e.g. InteractiveStartRequest). The
// runner spawns this test binary with the env var set, TestMain dispatches
@@ -53,6 +53,9 @@ func registerE2EResetRoutes(
api := router.Group("/api/v1/e2e")
api.DELETE("/reset/:workspaceId", handleE2EReset(repo, taskSvc, automationSvc, githubSvc, gitlabSvc, log))
if githubSvc != nil {
api.POST("/tasks/:id/remote-contribution", handleE2EAttachGitHubContribution(repo, taskSvc, githubSvc, log))
}
// Hidden-workflow factory: lets E2E tests cover the system-only
// workflow path (e.g. improve-kandev) without depending on the real
// bootstrap endpoint, which clones from GitHub and shells out to gh.
@@ -90,6 +93,87 @@ func registerE2EResetRoutes(
log.Info("registered E2E endpoints (test-only)")
}
type e2eAttachGitHubContributionRequest struct {
PRURL string `json:"pr_url"`
}
type e2eAttachGitHubContributionResponse struct {
Binding taskmodels.RemoteContribution `json:"binding"`
RemoteName string `json:"remote_name"`
}
type e2eTaskAccessAuthorizer interface {
AuthorizeTaskAccess(context.Context, string) error
}
// handleE2EAttachGitHubContribution resolves a mock-provider PR and persists
// the same server-authored binding that a remote-contribution task carries at
// launch. It exists only in the mock-agent E2E surface so tests can prepare a
// task before opening its session; callers cannot submit a writable binding.
func handleE2EAttachGitHubContribution(
repo *sqliterepo.Repository,
authorizer e2eTaskAccessAuthorizer,
githubSvc *github.Service,
log *logger.Logger,
) gin.HandlerFunc {
return func(c *gin.Context) {
var req e2eAttachGitHubContributionRequest
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.PRURL) == "" {
c.JSON(http.StatusBadRequest, gin.H{errKey: "pr_url is required"})
return
}
ctx := c.Request.Context()
taskID := c.Param("id")
if err := authorizer.AuthorizeTaskAccess(ctx, taskID); err != nil {
c.JSON(http.StatusNotFound, gin.H{errKey: "task not found"})
return
}
task, err := repo.GetTask(ctx, taskID)
if err != nil || task == nil {
c.JSON(http.StatusNotFound, gin.H{errKey: "task not found"})
return
}
resolution, err := githubSvc.ResolveRemoteContributionForWorkspace(
ctx, task.WorkspaceID, github.DefaultUserID, req.PRURL,
)
if err != nil {
log.Warn("e2e remote contribution resolution failed", zap.String("task_id", task.ID), zap.Error(err))
c.JSON(http.StatusUnprocessableEntity, gin.H{errKey: err.Error()})
return
}
link, err := repo.GetPrimaryTaskRepository(ctx, task.ID)
if err != nil || link == nil {
c.JSON(http.StatusBadRequest, gin.H{errKey: "task has no primary repository"})
return
}
metadata := make(map[string]interface{}, len(link.Metadata)+1)
for key, value := range link.Metadata {
metadata[key] = value
}
binding := resolution.Binding
if err := taskmodels.PutRemoteContribution(metadata, &binding); err != nil {
log.Error("e2e remote contribution binding validation failed", zap.String("task_id", task.ID), zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{errKey: "failed to persist remote contribution"})
return
}
link.Metadata = metadata
link.BaseBranch = binding.BaseBranch
link.CheckoutBranch = binding.HeadBranch
if err := repo.UpdateTaskRepository(ctx, link); err != nil {
log.Error("e2e remote contribution binding persistence failed", zap.String("task_id", task.ID), zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{errKey: "failed to persist remote contribution"})
return
}
c.JSON(http.StatusOK, e2eAttachGitHubContributionResponse{
Binding: binding,
RemoteName: binding.ContributionRemoteName(),
})
}
}
func handleE2EReset(
repo *sqliterepo.Repository,
taskSvc *taskservice.Service,
@@ -3,6 +3,7 @@ package backendapp
import (
"context"
"errors"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
@@ -10,6 +11,7 @@ import (
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/db"
@@ -22,6 +24,16 @@ type e2eResetTaskDeleterStub struct {
options taskservice.DeleteTaskOptions
}
type e2eAttachTaskAuthorizerStub struct {
err error
taskID string
}
func (s *e2eAttachTaskAuthorizerStub) AuthorizeTaskAccess(_ context.Context, taskID string) error {
s.taskID = taskID
return s.err
}
func (s *e2eResetTaskDeleterStub) DeleteTaskWithOptions(
_ context.Context,
taskID string,
@@ -46,6 +58,29 @@ func TestDeleteTaskForE2EResetDiscardsWorktreeChanges(t *testing.T) {
}
}
func TestE2EAttachGitHubContributionRejectsUnauthorizedTask(t *testing.T) {
gin.SetMode(gin.TestMode)
authorizer := &e2eAttachTaskAuthorizerStub{err: errors.New("task is not visible")}
response := httptest.NewRecorder()
context, _ := gin.CreateTestContext(response)
context.Params = gin.Params{{Key: "id", Value: "other-task"}}
context.Request = httptest.NewRequest(
"POST",
"/api/v1/e2e/tasks/other-task/remote-contribution",
strings.NewReader(`{"pr_url":"https://github.com/testorg/testrepo/pull/1"}`),
)
context.Request.Header.Set("Content-Type", "application/json")
handleE2EAttachGitHubContribution(nil, authorizer, nil, nil)(context)
if response.Code != 404 {
t.Fatalf("status = %d, want 404", response.Code)
}
if authorizer.taskID != "other-task" {
t.Fatalf("authorized task ID = %q, want other-task", authorizer.taskID)
}
}
func TestE2EResetDeletesWorkspaceGitHubAuthentication(t *testing.T) {
raw, err := db.OpenSQLite(filepath.Join(t.TempDir(), "e2e-reset.db"))
if err != nil {
@@ -127,6 +127,7 @@ func IsKnownSafeGitFlag(arg string) bool {
"--amend", "--allow-empty", "--soft", "--mixed", "--hard",
"--cached", "--force", "--source=HEAD", "--staged", "--worktree",
"--dry-run", "--no-verify", "--get-all", "--first-parent", "--is-ancestor",
"--git-path", "--count", "--merges",
"--refs",
"--src-prefix=", "--dst-prefix=",
}
+16 -15
View File
@@ -368,21 +368,22 @@ const (
ActionSessionProcessStatus = "session.process.status"
// Git worktree actions
ActionWorktreePull = "worktree.pull" // Pull from remote
ActionWorktreePush = "worktree.push" // Push to remote
ActionWorktreeReplaceContribution = "worktree.replace_contribution" // Replace the bound contribution branch
ActionWorktreeUseContribution = "worktree.use_contribution" // Adopt the bound contribution version
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
ActionWorktreeUnstage = "worktree.unstage" // Unstage files from index
ActionWorktreeDiscard = "worktree.discard" // Discard changes to files
ActionWorktreeCreatePR = "worktree.create_pr" // Create a pull request
ActionWorktreeRevertCommit = "worktree.revert_commit" // Revert a commit (staged, no new commit)
ActionWorktreeRenameBranch = "worktree.rename_branch" // Rename the current branch
ActionWorktreeReset = "worktree.reset" // Reset HEAD to a commit (soft/hard)
ActionWorktreePull = "worktree.pull" // Pull from remote
ActionWorktreePush = "worktree.push" // Push to remote
ActionWorktreeReplaceContribution = "worktree.replace_contribution" // Replace the bound contribution branch
ActionWorktreeUseContribution = "worktree.use_contribution" // Adopt the bound contribution version
ActionWorktreeContributionHistoryExplanation = "worktree.contribution_history_explanation" // Observe contribution history evidence
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
ActionWorktreeUnstage = "worktree.unstage" // Unstage files from index
ActionWorktreeDiscard = "worktree.discard" // Discard changes to files
ActionWorktreeCreatePR = "worktree.create_pr" // Create a pull request
ActionWorktreeRevertCommit = "worktree.revert_commit" // Revert a commit (staged, no new commit)
ActionWorktreeRenameBranch = "worktree.rename_branch" // Rename the current branch
ActionWorktreeReset = "worktree.reset" // Reset HEAD to a commit (soft/hard)
// User actions
ActionUserGet = "user.get"