PR #3627
Sections
Review

feat: explain divergent contribution histories

main ← feature/explain-pr-branch-ch-c71 61 files +2847 −312 PR #3627 ↗

Diverged task and PR histories now show a bounded local-rebase explanation with counts, plus a primary Compare action that opens the Changes panel.

Why this change

The diverged warning blamed the PR branch and gave no reason. Users could not tell if a local rebase caused the divergence or what changed.

What it does

Architecture, end to end

The header opens the explanation. The frontend hook shares one request. The backend validates identity and reads bounded reflogs locally.

flowchart LR
  Header[RemoteContributionHeaderActions] --> Hook[useContributionHistoryExplanation]
  Hook --> WS[WebSocket worktree.contribution_history_explanation]
  WS --> Handler[GitHandlers.wsContributionHistoryExplanation]
  Handler --> Client[agentctl Client.GitContributionHistoryExplanation]
  Client --> API[agentctl POST /api/v1/git/contribution/history-explanation]
  API --> Operator[GitOperator.ExplainContributionHistory]
  Operator --> Git[(Local Git reflogs)]
  Header --> Compare[requestContributionComparison]
  Compare --> Changes[Changes Panel]

Key code changes

Drag to pan. Use the + and − buttons to zoom. Click a node to open the full code. The arrows show how the parts interact.

drag to pan · +/− to zoom · click a node for details

agentctl
backend
web
func (g *GitOperator) ExplainContributionHistory(ctx context.Context, branch, localHead, remoteHead string) (*ContributionHistoryExplanationResult, error)
Click for details →

The operator checks reflogs and counts with timeouts and limits and never fetches or rewrites refs.

Result type
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"`
}
Bounded checks
func (g *GitOperator) ExplainContributionHistory(ctx context.Context, branch, expectedLocalHead, expectedRemoteHead string) (*ContributionHistoryExplanationResult, error) {
  result := newContributionHistoryResult(g.repoName, branch, expectedLocalHead, expectedRemoteHead)
  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
  }
  ontoHead, err := g.findContributionHistoryRebase(observeCtx, branch, expectedLocalHead, expectedRemoteHead)
  if err != nil {
    result.Reason = contributionHistoryFailureReason(err, contributionHistoryReasonNoMatch)
    return result, nil
  }
  result.Kind = contributionHistoryKindLocalRebase
  result.Reason = contributionHistoryReasonMatched
  result.OntoHead = ontoHead
  return result, nil
}
Reflog match
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 (h *GitHandlers) wsContributionHistoryExplanation(ctx context.Context, msg *ws.Message) (*ws.Message, error)
Click for details →

The handler validates branch and heads and forwards the read-only request to agentctl.

Request type
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"`
}
Handler
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)
}
Registration
func (h *GitHandlers) RegisterHandlers(d *ws.Dispatcher) {
  d.RegisterFunc(ws.ActionWorktreePull, h.wsPull)
  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)
}
func (c *Client) GitContributionHistoryExplanation(ctx context.Context, branch, localHead, remoteHead, repo string) (*ContributionHistoryExplanationResult, error)
Click for details →

The client posts the branch and heads to agentctl and decodes the bounded result.

Client call
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
}
func (s *Server) handleGitContributionHistoryExplanation(c *gin.Context)
Click for details →

The endpoint validates required fields and delegates to the Git operator.

Request type
type GitContributionHistoryExplanationRequest struct {
  Branch             string `json:"branch"`
  ExpectedLocalHead  string `json:"expected_local_head"`
  ExpectedRemoteHead string `json:"expected_remote_head"`
  Repo               string `json:"repo,omitempty"`
}
Handler
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, 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)
}
export function useContributionHistoryExplanation(target: ContributionHistoryExplanationTarget | null, enabled: boolean)
Click for details →

The hook shares one in-flight request per identity and discards stale responses.

Key and validation
export function contributionHistoryExplanationKey(target: ContributionHistoryExplanationTarget | null): string | null {
  if (!targetIsRequestable(target)) return null;
  return JSON.stringify([
    target.workspaceId,
    target.sessionId,
    target.repositoryScope,
    target.branch,
    target.expectedLocalHead,
    target.expectedRemoteHead,
  ]);
}

function targetIsRequestable(target: ContributionHistoryExplanationTarget | null): target is ContributionHistoryExplanationTarget {
  return Boolean(
    target?.sessionId && target.workspaceId && target.repositoryScope !== undefined &&
    target.branch && target.selectedPRKey &&
    isFullCommitSHA(target.expectedLocalHead) && isFullCommitSHA(target.expectedRemoteHead),
  );
}
Shared request
function startEntry(key: string, target: ContributionHistoryRequestTarget): ExplanationEntry {
  const entry: ExplanationEntry = { ...emptySnapshot(), status: "loading", listeners: new Set() };
  entries.set(key, entry);
  const client = getWebSocketClient();
  if (!client) { entry.status = "unavailable"; return entry; }
  const request = client.request<ContributionHistoryExplanation>(ACTION, {
    session_id: target.sessionId, repo: target.repositoryScope, branch: target.branch,
    expected_local_head: target.expectedLocalHead, expected_remote_head: target.expectedRemoteHead,
  }, REQUEST_TIMEOUT_MS);
  void request.then((response) => {
    if (entries.get(key) !== entry) return;
    if (!responseMatchesTarget(response, target)) {
      entry.status = "unavailable";
      entry.error = new Error("contribution history explanation identity changed");
    } else {
      entry.status = "ready"; entry.explanation = response;
    }
    notify(entry);
  }, (error) => { if (entries.get(key) !== entry) return; entry.status = "unavailable"; entry.error = error; notify(entry); });
  return entry;
}
Hook
export function useContributionHistoryExplanation(target: ContributionHistoryExplanationTarget | null, enabled: boolean) {
  const key = enabled ? contributionHistoryExplanationKey(requestTarget) : null;
  const [boundSnapshot, setBoundSnapshot] = useState<BoundExplanationSnapshot>(() => ({ key: null, snapshot: emptySnapshot() }));
  const snapshot = boundSnapshot.key === key ? boundSnapshot.snapshot : emptySnapshot();
  useEffect(() => {
    if (!key) { setBoundSnapshot({ key: null, snapshot: emptySnapshot() }); return; }
    const entry = getOrStartEntry(key, requestFields);
    const sync = () => { if (active) setBoundSnapshot({ key, snapshot: snapshotOf(entry) }); };
    entry.listeners.add(sync); sync();
    return () => { entry.listeners.delete(sync); if (entry.listeners.size === 0) entries.delete(key); };
  }, [key, requestFields]);
  return { ...snapshot, isLoading: snapshot.status === "loading" };
}
export function RemoteContributionHeaderActions(props: RemoteContributionHeaderActionsProps)
Click for details →

The menu shows local-rebase copy and counts and makes Compare the primary action.

Explanation body
function HistoryExplanationBody({ status, explanation }: { status: string; explanation: ContributionHistoryExplanation | null }) {
  const { t } = useTranslation();
  const localRebase = isConfirmedLocalRebase(explanation);
  return (
    <div className="space-y-2">
      <p className="font-normal leading-relaxed text-muted-foreground">
        {localRebase ? t("task:remoteContributionHistoryLocalRebase") : t("task:remoteContributionHistoryBody")}
      </p>
      {status === "loading" && <p data-testid="history-explanation-loading">{t("task:remoteContributionHistoryLoading")}</p>}
      {localRebase && explanation && (
        <div data-testid="history-counts">
          {typeof explanation.task_commit_count === "number" && <p>{t("task:remoteContributionTaskCommits", { count: explanation.task_commit_count })}</p>}
          {typeof explanation.published_commit_count === "number" && <p>{t("task:remoteContributionPublishedCommits", { count: explanation.published_commit_count })}</p>}
          {typeof explanation.new_base_commit_count === "number" && <p>{t("task:remoteContributionNewBaseCommits", { count: explanation.new_base_commit_count })}</p>}
        </div>
      )}
      {localRebase && <p>{t("task:remoteContributionHistoryGuidance")}</p>}
    </div>
  );
}
Compare wiring
export function requestContributionComparison(key: string) {
  currentRequest = { key, token: ++nextToken };
  if (typeof window !== "undefined") {
    window.dispatchEvent(new CustomEvent("switch-to-changes-tab"));
  }
  notify();
}
Menu trigger
const history = useContributionHistoryExplanation(contributionHistoryTarget ?? null, menu.open);
if (relation?.kind !== "diverged" || !resolution || !resolutionTarget || !policy) return null;
const comparisonKey = contributionHistoryExplanationKey(contributionHistoryTarget ?? null);
// menu.handleCompare closes the menu and calls requestContributionComparison(comparisonKey)
// Changes panel expands on token and focuses the heading
Read the changes as a list

Bounded local observation

apps/backend/internal/agentctl/server/process/git_contribution_history.go

The operator checks reflogs and counts with timeouts and limits and never fetches or rewrites refs.

Result type
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"`
}
Bounded checks
func (g *GitOperator) ExplainContributionHistory(ctx context.Context, branch, expectedLocalHead, expectedRemoteHead string) (*ContributionHistoryExplanationResult, error) {
  result := newContributionHistoryResult(g.repoName, branch, expectedLocalHead, expectedRemoteHead)
  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
  }
  ontoHead, err := g.findContributionHistoryRebase(observeCtx, branch, expectedLocalHead, expectedRemoteHead)
  if err != nil {
    result.Reason = contributionHistoryFailureReason(err, contributionHistoryReasonNoMatch)
    return result, nil
  }
  result.Kind = contributionHistoryKindLocalRebase
  result.Reason = contributionHistoryReasonMatched
  result.OntoHead = ontoHead
  return result, nil
}
Reflog match
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
}

WebSocket handler

apps/backend/internal/agent/handlers/git_handlers.go

The handler validates branch and heads and forwards the read-only request to agentctl.

Request type
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"`
}
Handler
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)
}
Registration
func (h *GitHandlers) RegisterHandlers(d *ws.Dispatcher) {
  d.RegisterFunc(ws.ActionWorktreePull, h.wsPull)
  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)
}

Agentctl client

apps/backend/internal/agent/runtime/agentctl/git.go

The client posts the branch and heads to agentctl and decodes the bounded result.

Client call
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
}

Agentctl HTTP endpoint

apps/backend/internal/agentctl/server/api/git.go

The endpoint validates required fields and delegates to the Git operator.

Request type
type GitContributionHistoryExplanationRequest struct {
  Branch             string `json:"branch"`
  ExpectedLocalHead  string `json:"expected_local_head"`
  ExpectedRemoteHead string `json:"expected_remote_head"`
  Repo               string `json:"repo,omitempty"`
}
Handler
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, 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)
}

Frontend shared hook

apps/web/hooks/domains/session/use-contribution-history-explanation.ts

The hook shares one in-flight request per identity and discards stale responses.

Key and validation
export function contributionHistoryExplanationKey(target: ContributionHistoryExplanationTarget | null): string | null {
  if (!targetIsRequestable(target)) return null;
  return JSON.stringify([
    target.workspaceId,
    target.sessionId,
    target.repositoryScope,
    target.branch,
    target.expectedLocalHead,
    target.expectedRemoteHead,
  ]);
}

function targetIsRequestable(target: ContributionHistoryExplanationTarget | null): target is ContributionHistoryExplanationTarget {
  return Boolean(
    target?.sessionId && target.workspaceId && target.repositoryScope !== undefined &&
    target.branch && target.selectedPRKey &&
    isFullCommitSHA(target.expectedLocalHead) && isFullCommitSHA(target.expectedRemoteHead),
  );
}
Shared request
function startEntry(key: string, target: ContributionHistoryRequestTarget): ExplanationEntry {
  const entry: ExplanationEntry = { ...emptySnapshot(), status: "loading", listeners: new Set() };
  entries.set(key, entry);
  const client = getWebSocketClient();
  if (!client) { entry.status = "unavailable"; return entry; }
  const request = client.request<ContributionHistoryExplanation>(ACTION, {
    session_id: target.sessionId, repo: target.repositoryScope, branch: target.branch,
    expected_local_head: target.expectedLocalHead, expected_remote_head: target.expectedRemoteHead,
  }, REQUEST_TIMEOUT_MS);
  void request.then((response) => {
    if (entries.get(key) !== entry) return;
    if (!responseMatchesTarget(response, target)) {
      entry.status = "unavailable";
      entry.error = new Error("contribution history explanation identity changed");
    } else {
      entry.status = "ready"; entry.explanation = response;
    }
    notify(entry);
  }, (error) => { if (entries.get(key) !== entry) return; entry.status = "unavailable"; entry.error = error; notify(entry); });
  return entry;
}
Hook
export function useContributionHistoryExplanation(target: ContributionHistoryExplanationTarget | null, enabled: boolean) {
  const key = enabled ? contributionHistoryExplanationKey(requestTarget) : null;
  const [boundSnapshot, setBoundSnapshot] = useState<BoundExplanationSnapshot>(() => ({ key: null, snapshot: emptySnapshot() }));
  const snapshot = boundSnapshot.key === key ? boundSnapshot.snapshot : emptySnapshot();
  useEffect(() => {
    if (!key) { setBoundSnapshot({ key: null, snapshot: emptySnapshot() }); return; }
    const entry = getOrStartEntry(key, requestFields);
    const sync = () => { if (active) setBoundSnapshot({ key, snapshot: snapshotOf(entry) }); };
    entry.listeners.add(sync); sync();
    return () => { entry.listeners.delete(sync); if (entry.listeners.size === 0) entries.delete(key); };
  }, [key, requestFields]);
  return { ...snapshot, isLoading: snapshot.status === "loading" };
}

Header menu and Compare

apps/web/components/task/remote-contribution-header-actions.tsx

The menu shows local-rebase copy and counts and makes Compare the primary action.

Explanation body
function HistoryExplanationBody({ status, explanation }: { status: string; explanation: ContributionHistoryExplanation | null }) {
  const { t } = useTranslation();
  const localRebase = isConfirmedLocalRebase(explanation);
  return (
    <div className="space-y-2">
      <p className="font-normal leading-relaxed text-muted-foreground">
        {localRebase ? t("task:remoteContributionHistoryLocalRebase") : t("task:remoteContributionHistoryBody")}
      </p>
      {status === "loading" && <p data-testid="history-explanation-loading">{t("task:remoteContributionHistoryLoading")}</p>}
      {localRebase && explanation && (
        <div data-testid="history-counts">
          {typeof explanation.task_commit_count === "number" && <p>{t("task:remoteContributionTaskCommits", { count: explanation.task_commit_count })}</p>}
          {typeof explanation.published_commit_count === "number" && <p>{t("task:remoteContributionPublishedCommits", { count: explanation.published_commit_count })}</p>}
          {typeof explanation.new_base_commit_count === "number" && <p>{t("task:remoteContributionNewBaseCommits", { count: explanation.new_base_commit_count })}</p>}
        </div>
      )}
      {localRebase && <p>{t("task:remoteContributionHistoryGuidance")}</p>}
    </div>
  );
}
Compare wiring
export function requestContributionComparison(key: string) {
  currentRequest = { key, token: ++nextToken };
  if (typeof window !== "undefined") {
    window.dispatchEvent(new CustomEvent("switch-to-changes-tab"));
  }
  notify();
}
Menu trigger
const history = useContributionHistoryExplanation(contributionHistoryTarget ?? null, menu.open);
if (relation?.kind !== "diverged" || !resolution || !resolutionTarget || !policy) return null;
const comparisonKey = contributionHistoryExplanationKey(contributionHistoryTarget ?? null);
// menu.handleCompare closes the menu and calls requestContributionComparison(comparisonKey)
// Changes panel expands on token and focuses the heading

Data and storage

The explanation is read-only and bounded. Counts are separate graph counts, not patch equivalence.

FieldTypeNotes
branchstringselected branch name, validated as safe branch ref
expected_local_headstring (40 hex)task HEAD at request time, must be full SHA
expected_remote_headstring (40 hex)published PR head at request time, must be full SHA
kindenumlocal_rebase or unexplained
reasonenummatched_reflog, no_matching_reflog, missing_objects, ambiguous, stale, limit, unavailable
onto_headstring (40 hex)commit onto which the rebase finished, when kind is local_rebase
task_commit_countinteger | nullcommits from onto_head to local head, omitted if merges or limit
published_commit_countinteger | nullcommits from old merge base to published head
new_base_commit_countinteger | nullcommits from old merge base to onto_head

Risk

4 / 10 Medium
1 low5 medium10 high

Why this score

  • Read-only Git observation with 2s timeout, 200 reflog and 2000 commit limits, and 1 MiB output cap.
  • No fetch, rewrite, or publish. Existing replace and restore flows keep their leases and confirmations.
  • Failure returns neutral copy. No new schema, flag, or background worker.

Trade-offs and review notes

Where to look first

  1. Verify ExplainContributionHistory checks branch and HEAD at start and end and rejects unfinished rebase states.
  2. Confirm frontend discards responses when workspace, branch, or heads change and that Compare expands the Changes panel.
  3. Check that counts require a unique merge base and linear ranges and that no reflog text is logged.