Files
kandev/apps/web/components/task/changes-panel-timeline-grouping.test.tsx
T
João Salavisa 5c5e7bf6a7 fix(changes-panel): scope commits/diff to live branch divergence; flatten single-repo (#816)
* fix(changes-panel): scope commits/diff to live branch divergence; flatten single-repo

Two bugs in the COMMITS panel made it surface commits and file changes
that were already on main:

1. The commit list and the cumulative diff both anchored to a stale base.
   Commit list used `git merge-base HEAD <local-branch>` and the file diff
   used the session's stored base SHA captured once at session start. On
   long-lived worktrees the local main ref lags far behind origin/main, so
   the merge-base lands on an old SHA; once the agent merges main back in,
   the stored base is also stale. Both end up sweeping in dozens of main
   commits as if they were the branch's work.

2. `git log <base>..HEAD` follows both parents of a merge commit by
   default, surfacing main commits brought in via merges from main.

Fixes:
- agentctl computes merge-base against `origin/<target_branch>` (with a
  local-ref fallback for unfetched/local-only branches), and uses that
  for both the commit log and the cumulative diff. Mirrors GitHub's
  three-dot PR diff semantics — anchor to live divergence with the
  upstream, not a session-start snapshot.
- `git log` now passes `--first-parent` so only the branch's own line of
  work plus its merge commits show up; commits brought in via merges from
  main are excluded.

Frontend: drop the per-repo "REPOSITORY" sub-header in the COMMITS panel
for single-repo workspaces. CommitsRepoGroup gains a `showHeader` prop;
when the workspace has one untagged group the section renders flat with
the per-repo Push / PR (and Stage all / Commit / Unstage all) buttons
lifted into the section header. Multi-repo rendering unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: address PR review feedback

- Scope --first-parent to the divergence-range path only (Greptile P1).
  The flag is wrong for open-ended history calls — future activity
  widgets etc. need the full graph.
- Log empty merge-base case in cumulative diff (Greptile P2). Silent
  fallback to stored base was invisible during diagnostics.
- Rename TestRunGitCumulativeDiffForRepo_PrefersOriginRef →
  TestComputeMergeBase_CorrectAnchorForCumulativeDiff (Claude). Test
  exercises computeMergeBase, not the runGitCumulativeDiffForRepo
  conditional; new name reflects what it actually proves.
- Add TestGetLog_NoBaseReturnsFullGraph to lock in the --first-parent
  scoping.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: address PR review round 2

- Drop dead ActionButtonsSection from changes-panel-timeline.tsx (unused
  export, no consumers anywhere). Brings the file from 640 → 516 lines,
  comfortably under the 600-line TS limit Claude flagged.
- Extract isSingleRepoGroup helper into lib/group-by-repo.ts and use it
  in all three sites (CommitsSection, FileListBody, FileListSection) so
  single-repo detection has one source of truth.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 17:28:04 +01:00

244 lines
8.3 KiB
TypeScript

import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, cleanup, fireEvent, within } from "@testing-library/react";
import type { ComponentProps } from "react";
vi.mock("./changes-panel-file-row", () => ({
FileRow: ({ file }: { file: { path: string } }) => <li data-testid="file-row">{file.path}</li>,
BulkActionBar: () => null,
DefaultActionButtons: () => null,
}));
vi.mock("./commit-row", () => ({
CommitRow: ({ commit, isLatest }: { commit: { commit_sha: string }; isLatest: boolean }) => (
<li data-testid="commit-row" data-sha={commit.commit_sha} data-is-latest={String(isLatest)}>
{commit.commit_sha}
</li>
),
}));
vi.mock("@/hooks/use-multi-select", () => ({
useMultiSelect: () => ({
selectedPaths: new Set<string>(),
isSelected: () => false,
handleClick: vi.fn(),
clearSelection: vi.fn(),
}),
}));
import { FileListSection, CommitsSection } from "./changes-panel-timeline";
const REPO_HEADER_TID = "changes-repo-header";
const COMMIT_ROW_TID = "commit-row";
const COMMITS_SECTION_TOGGLE_TID = "commits-section-collapse-toggle";
const ARIA_EXPANDED = "aria-expanded";
afterEach(cleanup);
type Props = ComponentProps<typeof FileListSection>;
const baseProps: Omit<Props, "files" | "variant" | "isLast" | "actionLabel" | "onAction"> = {
pendingStageFiles: new Set(),
onOpenDiff: vi.fn(),
onEditFile: vi.fn(),
onStage: vi.fn(),
onUnstage: vi.fn(),
onDiscard: vi.fn(),
};
function file(path: string, repo?: string): Props["files"][number] {
return {
path,
status: "modified",
staged: false,
plus: 1,
minus: 0,
oldPath: undefined,
repositoryName: repo,
};
}
describe("FileListSection — multi-repo grouping", () => {
it("renders a flat file list (no per-repo header) for single-repo workspaces", () => {
// Single-repo: drop the redundant per-repo sub-header above a flat file
// list. Action buttons (Stage all / Commit / Unstage all) move up to the
// section header so they remain accessible.
render(
<FileListSection
{...baseProps}
variant="unstaged"
isLast={false}
actionLabel="Stage all"
onAction={() => undefined}
onRepoAction={() => undefined}
files={[file("a.ts"), file("b.ts")]}
/>,
);
expect(screen.queryAllByTestId(REPO_HEADER_TID)).toHaveLength(0);
expect(screen.getAllByTestId("file-row")).toHaveLength(2);
// Section-level action button is still rendered.
expect(screen.getByTestId("repo-group-action").textContent).toContain("Stage all");
});
it("renders one header per repo when 2+ repos are present", () => {
render(
<FileListSection
{...baseProps}
variant="unstaged"
isLast={false}
actionLabel="Stage all"
onAction={() => undefined}
files={[
file("src/app.tsx", "frontend"),
file("src/api.ts", "frontend"),
file("handlers/task.go", "backend"),
]}
/>,
);
const headers = screen.getAllByTestId(REPO_HEADER_TID);
expect(headers).toHaveLength(2);
expect(headers[0].textContent).toContain("frontend");
expect(headers[0].textContent).toContain("2");
expect(headers[1].textContent).toContain("backend");
expect(headers[1].textContent).toContain("1");
});
it("shows a header for a single named repo too", () => {
render(
<FileListSection
{...baseProps}
variant="unstaged"
isLast={false}
actionLabel="Stage all"
onAction={() => undefined}
files={[file("a.ts", "only-repo")]}
/>,
);
expect(screen.getByTestId(REPO_HEADER_TID).textContent).toContain("only-repo");
});
it("collapses one repo independently when its header is clicked", () => {
render(
<FileListSection
{...baseProps}
variant="unstaged"
isLast={false}
actionLabel="Stage all"
onAction={() => undefined}
files={[
file("src/app.tsx", "frontend"),
file("src/api.ts", "frontend"),
file("handlers/task.go", "backend"),
]}
/>,
);
expect(screen.getAllByTestId("file-row")).toHaveLength(3);
const groups = screen.getAllByTestId("changes-repo-group");
const frontendGroup = groups.find(
(g) => g.getAttribute("data-repository-name") === "frontend",
)!;
const frontendHeader = within(frontendGroup).getByTestId(REPO_HEADER_TID);
fireEvent.click(frontendHeader);
// frontend's two rows hidden, backend's one row still visible
expect(screen.getAllByTestId("file-row")).toHaveLength(1);
expect(frontendHeader.getAttribute(ARIA_EXPANDED)).toBe("false");
fireEvent.click(frontendHeader);
expect(screen.getAllByTestId("file-row")).toHaveLength(3);
expect(frontendHeader.getAttribute(ARIA_EXPANDED)).toBe("true");
});
});
describe("CommitsSection", () => {
type CommitProps = ComponentProps<typeof CommitsSection>;
function commit(sha: string, message: string, repo?: string): CommitProps["commits"][number] {
return {
commit_sha: sha,
commit_message: message,
insertions: 1,
deletions: 0,
pushed: false,
repository_name: repo,
};
}
it("renders the commits section header collapsed by default", () => {
render(<CommitsSection commits={[commit("abc123", "first")]} isLast />);
// Commits section is collapsed by default; the toggle reflects that and
// the commit rows are not in the DOM until the user expands the section.
const toggle = screen.getByTestId(COMMITS_SECTION_TOGGLE_TID);
expect(toggle.getAttribute(ARIA_EXPANDED)).toBe("false");
expect(screen.queryByTestId(COMMIT_ROW_TID)).toBeNull();
fireEvent.click(toggle);
expect(toggle.getAttribute(ARIA_EXPANDED)).toBe("true");
expect(screen.getByTestId(COMMIT_ROW_TID)).toBeTruthy();
});
it("groups commits per repo when 2+ repos are present", () => {
render(
<CommitsSection
commits={[
commit("c1", "frontend change", "frontend"),
commit("c2", "backend change", "backend"),
commit("c3", "another frontend", "frontend"),
]}
isLast
/>,
);
fireEvent.click(screen.getByTestId(COMMITS_SECTION_TOGGLE_TID));
const headers = screen.getAllByTestId("commits-repo-header");
expect(headers).toHaveLength(2);
expect(headers[0].textContent).toContain("frontend");
expect(headers[0].textContent).toContain("2");
expect(headers[1].textContent).toContain("backend");
expect(headers[1].textContent).toContain("1");
expect(screen.getAllByTestId(COMMIT_ROW_TID)).toHaveLength(3);
});
it("renders commits flat (no commits-repo header) for single-repo workspaces", () => {
// Single-repo: drop the per-repo "REPOSITORY" sub-header. Push / PR
// buttons live in the section header instead.
render(
<CommitsSection
commits={[commit("c1", "msg"), commit("c2", "msg")]}
isLast
onRepoPush={() => undefined}
/>,
);
fireEvent.click(screen.getByTestId(COMMITS_SECTION_TOGGLE_TID));
expect(screen.queryAllByTestId("commits-repo-header")).toHaveLength(0);
expect(screen.getAllByTestId(COMMIT_ROW_TID)).toHaveLength(2);
});
// Regression: previously isLatest was computed against the merged list, so
// only ONE commit globally was marked latest. Each repo's newest unpushed
// commit must be latest in its own group — otherwise revert/amend buttons
// are absent on every repo except one, and clicking revert via context menu
// hits the backend's "can only revert latest commit" gate.
it("marks each repo's newest unpushed commit as latest (per-repo, not global)", () => {
render(
<CommitsSection
commits={[
// Insertion order is newest-first within each repo.
commit("frontend-new", "frontend latest", "frontend"),
commit("frontend-old", "frontend older", "frontend"),
commit("backend-only", "backend latest", "backend"),
]}
isLast
onRevertCommit={() => undefined}
/>,
);
fireEvent.click(screen.getByTestId(COMMITS_SECTION_TOGGLE_TID));
const rows = screen.getAllByTestId(COMMIT_ROW_TID);
const latestByShas = rows
.filter((r) => r.getAttribute("data-is-latest") === "true")
.map((r) => r.getAttribute("data-sha"));
expect(latestByShas.sort()).toEqual(["backend-only", "frontend-new"]);
});
});