feat: explain divergent contribution histories (#3627)
This commit is contained in:
@@ -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=",
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user