9e8079de69
Bundles a string of multi-repo fixes and refactors that came up while testing the feature end-to-end: - workspace_path stored on multi-repo TaskEnvironment was one level too high, so agentctl booted with the wrong WorkDir on cold start; fixed in resolveTaskEnvWorkspacePath + GetWorkspaceInfoForSession. - Stage/Unstage/Discard/Commit/Revert/Reset/Amend now thread `repo` through agentctl client → WS handler → frontend hook → SessionGit fan-out, so the right git repo is targeted (was running at the task root, which isn't a git repo for multi-repo tasks). - agentctl GitLog is multi-repo aware: when no `repo` is specified and per-repo trackers exist, fans out one log per repo and merges sorted newest-first with RepositoryName stamped on each commit. - Per-repo grouping for files (Changes panel) and commits, with collapsible group headers; commits section defaults to collapsed. Algorithm extracted into one generic groupByRepositoryName helper (was copy-pasted three times). - Task creation dialog rewritten around a unified `fs.repositories[]` list — no more "primary vs extras" split. Chip-style row with one pill per repo + branch, "+" to add, x to remove, GitHub URL toggle inline. Discovered (on-machine) repos appear in any chip's dropdown. Removes ~6 fields and 3 helpers from the dialog state. - Branch listing endpoints collapsed to one: GET /workspaces/:id/branches?repository_id=X | path=Y Frontend collapses to a single useBranches hook with a tagged-union source. Drops two HTTP routes, two service methods, two API client funcs, and one whole hook file. Tests added across the chain: workspace_path resolver, sort-commits- by-date, manager poll-mode propagation, RepoSubpaths, chip grouping + collapse + per-repo isLatest, group-by-repo helper, dialog payload builder + chip wiring + discovered-repo flow.
236 lines
7.6 KiB
TypeScript
236 lines
7.6 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";
|
|
|
|
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 no repo header when files lack a repository_name (single-repo)", () => {
|
|
render(
|
|
<FileListSection
|
|
{...baseProps}
|
|
variant="unstaged"
|
|
isLast={false}
|
|
actionLabel="Stage all"
|
|
onAction={() => undefined}
|
|
files={[file("a.ts"), file("b.ts")]}
|
|
/>,
|
|
);
|
|
expect(screen.queryAllByTestId(REPO_HEADER_TID)).toHaveLength(0);
|
|
expect(screen.getAllByTestId("file-row")).toHaveLength(2);
|
|
});
|
|
|
|
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("starts collapsed by default — commits are reference info, not the primary signal", () => {
|
|
render(
|
|
<CommitsSection
|
|
commits={[commit("abc123", "first")]}
|
|
isLast
|
|
/>,
|
|
);
|
|
// Header is rendered, but commit rows are hidden until the user expands.
|
|
expect(screen.queryByTestId("commit-row")).toBeNull();
|
|
const toggle = screen.getByTestId("commits-section-collapse-toggle");
|
|
expect(toggle.getAttribute("aria-expanded")).toBe("false");
|
|
});
|
|
|
|
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
|
|
/>,
|
|
);
|
|
// Expand the section first so the inner repo headers and commit rows render.
|
|
fireEvent.click(screen.getByTestId("commits-section-collapse-toggle"));
|
|
|
|
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")).toHaveLength(3);
|
|
});
|
|
|
|
it("renders flat (no per-repo headers) for single-repo or untagged commits", () => {
|
|
render(
|
|
<CommitsSection
|
|
commits={[commit("c1", "msg"), commit("c2", "msg")]}
|
|
isLast
|
|
/>,
|
|
);
|
|
fireEvent.click(screen.getByTestId("commits-section-collapse-toggle"));
|
|
expect(screen.queryAllByTestId("commits-repo-header")).toHaveLength(0);
|
|
expect(screen.getAllByTestId("commit-row")).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-collapse-toggle"));
|
|
const rows = screen.getAllByTestId("commit-row");
|
|
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"]);
|
|
});
|
|
});
|