[codex] Workspace diff polish (#6383)

## Thinking Path

> - Paperclip gives operators a workspace diff plugin so they can
inspect agent changes before review
> - The diff view needs reliable base-ref defaults and controls that
stay usable while scrolling large diffs
> - The working branch mixed those plugin improvements with unrelated
server and cloud work
> - Keeping the workspace diff plugin changes isolated makes them easy
to test and review
> - This pull request polishes the workspace diff plugin controls,
base-ref behavior, and sticky headers
> - The benefit is a more predictable diff review surface for agent
workspaces

## What Changed

- Fixed workspace diff default base-ref resolution.
- Improved split/unified and working-tree/against-ref pane controls.
- Made workspace diff headers stay sticky while scrolling.
- Added a review screenshot at
`screenshots/PAP-9841-workspace-diff.png`.

## Verification

- `pnpm install --frozen-lockfile --ignore-scripts`
- `pnpm --filter @paperclipai/plugin-sdk build`
- `pnpm --filter @paperclipai/plugin-workspace-diff exec vitest run
tests/plugin.spec.ts`
- Result: 9 tests passed.

## Risks

- UI-only plugin branch with low data risk.
- The default base-ref inference should be reviewed against unusual
worktree/upstream combinations.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5-based coding agent with local shell/git/tool use.
Exact hosted model ID and context-window size are not exposed by the
local Paperclip adapter runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-05-19 15:51:13 -05:00 committed by GitHub
parent d67347be77
commit 43c5bb81b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 418 additions and 27 deletions

View file

@ -3,7 +3,17 @@ import { usePluginData, usePluginToast } from "@paperclipai/plugin-sdk/ui";
import { DIFFS_TAG_NAME, getSingularPatch } from "@pierre/diffs";
import type { PatchDiffProps } from "@pierre/diffs/react";
import { useFileDiffInstance } from "@pierre/diffs/react";
import { createElement, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
createElement,
type KeyboardEvent,
type PointerEvent,
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
diffSummary,
fileName,
@ -23,6 +33,12 @@ type DiffViewMode = "working-tree" | "head";
type LucideIconProps = { size?: number };
const DEFAULT_FILE_SIDEBAR_WIDTH = 280;
const MIN_FILE_SIDEBAR_WIDTH = 220;
const MAX_FILE_SIDEBAR_WIDTH = 520;
const FILE_SIDEBAR_WIDTH_STEP = 16;
const FILE_SIDEBAR_WIDTH_STORAGE_KEY = "paperclip.workspace-diff.files-sidebar-width";
function makeLucideIcon(paths: ReactNode) {
return function LucideIcon({ size = 16 }: LucideIconProps) {
return (
@ -57,6 +73,11 @@ function readInitialView(): DiffViewMode {
return new URLSearchParams(window.location.search).get("diffView") === "head" ? "head" : "working-tree";
}
function hasInitialViewParam() {
if (typeof window === "undefined") return false;
return new URLSearchParams(window.location.search).has("diffView");
}
function readInitialBaseRef() {
if (typeof window === "undefined") return "";
return new URLSearchParams(window.location.search).get("baseRef") ?? "";
@ -80,6 +101,51 @@ function iconButtonClass(active = false) {
].join(" ");
}
function clampFileSidebarWidth(width: number) {
return Math.min(MAX_FILE_SIDEBAR_WIDTH, Math.max(MIN_FILE_SIDEBAR_WIDTH, width));
}
function readStoredFileSidebarWidth() {
if (typeof window === "undefined") return DEFAULT_FILE_SIDEBAR_WIDTH;
try {
const stored = window.localStorage.getItem(FILE_SIDEBAR_WIDTH_STORAGE_KEY);
if (!stored) return DEFAULT_FILE_SIDEBAR_WIDTH;
const parsed = Number.parseInt(stored, 10);
return Number.isFinite(parsed) ? clampFileSidebarWidth(parsed) : DEFAULT_FILE_SIDEBAR_WIDTH;
} catch {
return DEFAULT_FILE_SIDEBAR_WIDTH;
}
}
function writeStoredFileSidebarWidth(width: number) {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(FILE_SIDEBAR_WIDTH_STORAGE_KEY, String(clampFileSidebarWidth(width)));
} catch {
// Storage can be unavailable; keep resize interactive even when persistence fails.
}
}
function useIsDesktopDiffLayout() {
const [isDesktop, setIsDesktop] = useState(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
return window.matchMedia("(min-width: 1024px)").matches;
});
useEffect(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
const query = window.matchMedia("(min-width: 1024px)");
const update = () => setIsDesktop(query.matches);
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
}, []);
return isDesktop;
}
function warningText(file: DiffFileViewModel) {
if (file.binary) return "Binary file";
if (file.oversized) return "Too large to render";
@ -260,9 +326,11 @@ export function ErrorState({
function FileDiffPanel({
file,
mode,
lineWrap,
}: {
file: DiffFileViewModel;
mode: DiffRenderMode;
lineWrap: boolean;
}) {
const warning = warningText(file);
if (warning) {
@ -295,7 +363,7 @@ function FileDiffPanel({
patch={patch.patch}
options={{
diffStyle: mode,
overflow: "scroll",
overflow: lineWrap ? "wrap" : "scroll",
disableLineNumbers: false,
themeType: "system",
}}
@ -343,25 +411,38 @@ function CollapsedFilePanel({
export function ChangesTab({ context }: PluginDetailTabProps) {
const toast = usePluginToast();
const [mode, setMode] = useState<DiffRenderMode>("split");
const [lineWrap, setLineWrap] = useState(false);
const [view, setView] = useState<DiffViewMode>(() => readInitialView());
const [baseRef, setBaseRef] = useState(() => readInitialBaseRef());
const baseRefTouchedRef = useRef(Boolean(baseRef.trim()));
const viewTouchedRef = useRef(hasInitialViewParam());
const [includeUntracked, setIncludeUntracked] = useState(false);
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(() => new Set());
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [fileSidebarWidth, setFileSidebarWidth] = useState(() => readStoredFileSidebarWidth());
const [fileSidebarResizing, setFileSidebarResizing] = useState(false);
const fileSidebarWidthRef = useRef(fileSidebarWidth);
const fileSidebarDragRef = useRef<{ startX: number; startWidth: number } | null>(null);
const fileSectionRefs = useRef(new Map<string, HTMLElement>());
const diffScrollRef = useRef<HTMLElement | null>(null);
const scrollSyncFrameRef = useRef<number | null>(null);
const usesDesktopDiffLayout = useIsDesktopDiffLayout();
const requestedBaseRef = baseRef.trim();
const effectiveView = view === "head" && !requestedBaseRef ? "working-tree" : view;
const fileSidebarStyle = useMemo(
() => usesDesktopDiffLayout ? { width: `${fileSidebarWidth}px` } : undefined,
[fileSidebarWidth, usesDesktopDiffLayout],
);
const params = useMemo(() => ({
workspaceId: context.entityId,
companyId: context.companyId ?? "",
projectId: context.projectId ?? "",
entityType: context.entityType,
view,
baseRef: baseRef.trim() || null,
view: effectiveView,
baseRef: requestedBaseRef || null,
includeUntracked,
}), [baseRef, context.companyId, context.entityId, context.entityType, context.projectId, includeUntracked, view]);
}), [context.companyId, context.entityId, context.entityType, context.projectId, effectiveView, includeUntracked, requestedBaseRef]);
const { data, loading, error, refresh } = usePluginData<WorkspaceDiffData>("workspace-diff", params);
const files = useMemo(() => toFileViewModels(data), [data]);
@ -414,11 +495,70 @@ export function ChangesTab({ context }: PluginDetailTabProps) {
});
}, [syncSelectedPathFromScroll]);
const commitFileSidebarWidth = useCallback((nextWidth: number) => {
const clamped = clampFileSidebarWidth(nextWidth);
fileSidebarWidthRef.current = clamped;
setFileSidebarWidth(clamped);
writeStoredFileSidebarWidth(clamped);
}, []);
const handleFileSidebarPointerDown = useCallback((event: PointerEvent<HTMLDivElement>) => {
if (!usesDesktopDiffLayout) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
fileSidebarDragRef.current = {
startX: event.clientX,
startWidth: fileSidebarWidthRef.current,
};
setFileSidebarResizing(true);
}, [usesDesktopDiffLayout]);
const handleFileSidebarPointerMove = useCallback((event: PointerEvent<HTMLDivElement>) => {
const drag = fileSidebarDragRef.current;
if (!drag) return;
const nextWidth = clampFileSidebarWidth(drag.startWidth + event.clientX - drag.startX);
fileSidebarWidthRef.current = nextWidth;
setFileSidebarWidth(nextWidth);
}, []);
const endFileSidebarResize = useCallback(() => {
if (!fileSidebarDragRef.current) return;
fileSidebarDragRef.current = null;
setFileSidebarResizing(false);
writeStoredFileSidebarWidth(fileSidebarWidthRef.current);
}, []);
const handleFileSidebarKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
if (!usesDesktopDiffLayout) return;
if (event.key === "ArrowLeft") {
event.preventDefault();
commitFileSidebarWidth(fileSidebarWidth - FILE_SIDEBAR_WIDTH_STEP);
} else if (event.key === "ArrowRight") {
event.preventDefault();
commitFileSidebarWidth(fileSidebarWidth + FILE_SIDEBAR_WIDTH_STEP);
} else if (event.key === "Home") {
event.preventDefault();
commitFileSidebarWidth(MIN_FILE_SIDEBAR_WIDTH);
} else if (event.key === "End") {
event.preventDefault();
commitFileSidebarWidth(MAX_FILE_SIDEBAR_WIDTH);
}
}, [commitFileSidebarWidth, fileSidebarWidth, usesDesktopDiffLayout]);
useEffect(() => {
const defaultBaseRef = data?.defaultBaseRef?.trim();
if (!defaultBaseRef || baseRef.trim() || baseRefTouchedRef.current) return;
setBaseRef(defaultBaseRef);
}, [baseRef, data?.defaultBaseRef]);
if (!defaultBaseRef) return;
if (!baseRef.trim() && !baseRefTouchedRef.current) {
setBaseRef(defaultBaseRef);
}
if (view === "working-tree" && !viewTouchedRef.current) {
setView("head");
}
}, [baseRef, data?.defaultBaseRef, view]);
useEffect(() => {
if (files.length === 0) {
@ -438,6 +578,19 @@ export function ChangesTab({ context }: PluginDetailTabProps) {
};
}, []);
useEffect(() => {
if (!fileSidebarResizing || typeof document === "undefined") return;
const previousCursor = document.body.style.cursor;
const previousUserSelect = document.body.style.userSelect;
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
return () => {
document.body.style.cursor = previousCursor;
document.body.style.userSelect = previousUserSelect;
};
}, [fileSidebarResizing]);
const copyPath = async (filePath: string) => {
try {
await navigator.clipboard.writeText(filePath);
@ -475,11 +628,37 @@ export function ChangesTab({ context }: PluginDetailTabProps) {
Unified
</button>
</div>
<button
key="line-wrap"
type="button"
className={buttonClass(lineWrap)}
onClick={() => setLineWrap((value) => !value)}
title={lineWrap ? "Disable line wrapping" : "Enable line wrapping"}
aria-pressed={lineWrap}
>
{lineWrap ? "Wrap on" : "Wrap lines"}
</button>
<div key="view" className="inline-flex gap-1" aria-label="Diff comparison">
<button key="working-tree" type="button" className={buttonClass(view === "working-tree")} onClick={() => setView("working-tree")}>
<button
key="working-tree"
type="button"
className={buttonClass(effectiveView === "working-tree")}
onClick={() => {
viewTouchedRef.current = true;
setView("working-tree");
}}
>
Working tree
</button>
<button key="head" type="button" className={buttonClass(view === "head")} onClick={() => setView("head")}>
<button
key="head"
type="button"
className={buttonClass(effectiveView === "head")}
onClick={() => {
viewTouchedRef.current = true;
setView("head");
}}
>
Against ref
</button>
</div>
@ -526,8 +705,12 @@ export function ChangesTab({ context }: PluginDetailTabProps) {
) : files.length === 0 ? (
<EmptyState />
) : (
<div key="content" className="grid gap-3 lg:h-[70vh] lg:min-h-[560px] lg:max-h-[820px] lg:grid-cols-[280px_minmax(0,1fr)]">
<aside key="files" className="flex min-w-0 flex-col border border-border bg-background lg:h-full lg:overflow-hidden">
<div key="content" className="flex flex-col gap-3 lg:h-[70vh] lg:min-h-[560px] lg:max-h-[820px] lg:flex-row">
<aside
key="files"
className="relative flex min-w-0 flex-col border border-border bg-background lg:h-full lg:shrink-0 lg:overflow-hidden"
style={fileSidebarStyle}
>
<div key="heading" className="border-b border-border px-3 py-2 text-xs font-medium uppercase tracking-[0.14em] text-muted-foreground">
Files
</div>
@ -544,12 +727,33 @@ export function ChangesTab({ context }: PluginDetailTabProps) {
/>
))}
</div>
<div
role="separator"
aria-label="Resize file list"
aria-orientation="vertical"
aria-valuemin={MIN_FILE_SIDEBAR_WIDTH}
aria-valuemax={MAX_FILE_SIDEBAR_WIDTH}
aria-valuenow={fileSidebarWidth}
tabIndex={0}
className={[
"absolute inset-y-0 right-0 z-20 hidden w-3 cursor-col-resize touch-none outline-none lg:block",
"before:absolute before:inset-y-0 before:left-1/2 before:w-px before:-translate-x-1/2 before:bg-transparent before:transition-colors",
"hover:before:bg-border focus-visible:before:bg-ring",
fileSidebarResizing ? "before:bg-ring" : "",
].join(" ")}
onPointerDown={handleFileSidebarPointerDown}
onPointerMove={handleFileSidebarPointerMove}
onPointerUp={endFileSidebarResize}
onPointerCancel={endFileSidebarResize}
onLostPointerCapture={endFileSidebarResize}
onKeyDown={handleFileSidebarKeyDown}
/>
</aside>
<main
key="diffs"
ref={diffScrollRef}
className="max-h-[70vh] min-w-0 space-y-3 overflow-auto lg:h-full lg:max-h-none lg:pr-1"
className="max-h-[70vh] min-w-0 flex-1 space-y-3 overflow-auto lg:h-full lg:max-h-none lg:pr-1"
onScroll={handleDiffScroll}
>
{files
@ -559,7 +763,10 @@ export function ChangesTab({ context }: PluginDetailTabProps) {
ref={setFileSectionRef(file.path)}
className={file.path === selectedFile?.path ? "scroll-mt-2" : undefined}
>
<div key="header" className="flex min-w-0 items-center justify-between gap-3 border border-b-0 border-border bg-muted/35 px-3 py-2">
<div
key="header"
className="sticky top-0 z-30 flex min-w-0 items-center justify-between gap-3 border border-b-0 border-border bg-background px-3 py-2 shadow-sm"
>
<div key="left" className="flex min-w-0 items-start gap-2">
<button
key="collapse"
@ -599,7 +806,7 @@ export function ChangesTab({ context }: PluginDetailTabProps) {
</div>
</div>
{expandedFiles.has(file.path) ? (
<FileDiffPanel key="diff" file={file} mode={mode} />
<FileDiffPanel key="diff" file={file} mode={mode} lineWrap={lineWrap} />
) : (
<CollapsedFilePanel
key="collapsed"

View file

@ -1,4 +1,4 @@
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
import { definePlugin, runWorker, type PluginContext } from "@paperclipai/plugin-sdk";
import { workspaceDiffQuerySchema } from "./contracts.js";
import { workspaceDiffService } from "./workspace-diff.js";
@ -23,6 +23,25 @@ export function resolveDefaultBaseRef(input: {
?? readOptionalString(input.projectWorkspaceRepoRef);
}
async function resolveProjectWorkspaceDefaultBaseRef(input: {
ctx: PluginContext;
projectId: string;
companyId: string;
projectWorkspaceId?: string | null;
}): Promise<string | null> {
if (!input.projectId) return null;
const workspaces = await input.ctx.projects.listWorkspaces(input.projectId, input.companyId);
const projectWorkspace = input.projectWorkspaceId
? workspaces.find((candidate) => candidate.id === input.projectWorkspaceId)
: workspaces.find((candidate) => candidate.isPrimary) ?? workspaces[0] ?? null;
return projectWorkspace
? resolveDefaultBaseRef({
projectWorkspaceDefaultRef: projectWorkspace.defaultRef,
projectWorkspaceRepoRef: projectWorkspace.repoRef,
})
: null;
}
const plugin = definePlugin({
async setup(ctx) {
ctx.logger.info(`${PLUGIN_NAME} plugin setup`);
@ -61,15 +80,13 @@ const plugin = definePlugin({
throw new Error("Workspace not found");
}
let projectWorkspaceDefaultBaseRef: string | null = null;
if (!readOptionalString(workspace.baseRef) && workspace.projectWorkspaceId) {
const workspaces = await ctx.projects.listWorkspaces(workspace.projectId, companyId);
const projectWorkspace = workspaces.find((candidate) => candidate.id === workspace.projectWorkspaceId);
projectWorkspaceDefaultBaseRef = projectWorkspace
? resolveDefaultBaseRef({
projectWorkspaceDefaultRef: projectWorkspace.defaultRef,
projectWorkspaceRepoRef: projectWorkspace.repoRef,
})
: null;
if (!readOptionalString(workspace.baseRef)) {
projectWorkspaceDefaultBaseRef = await resolveProjectWorkspaceDefaultBaseRef({
ctx,
projectId: workspace.projectId || readString(params.projectId),
companyId,
projectWorkspaceId: workspace.projectWorkspaceId,
});
}
return workspaceDiff.getDiff({

View file

@ -645,6 +645,57 @@ async function resolveHeadSha(cwd: string) {
}
}
async function resolveVerifiedGitRef(cwd: string, refName: string) {
const trimmed = refName.trim();
if (!trimmed) return null;
try {
await execFileAsync("git", ["-C", cwd, "rev-parse", "--verify", "--quiet", `${trimmed}^{commit}`], {
cwd,
timeout: GIT_TIMEOUT_MS,
maxBuffer: 128 * 1024,
});
return trimmed;
} catch {
return null;
}
}
async function resolveGitUpstreamRef(cwd: string) {
try {
const upstream = (await execFileAsync(
"git",
["-C", cwd, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
{
cwd,
timeout: GIT_TIMEOUT_MS,
maxBuffer: 128 * 1024,
},
)).stdout.trim();
return upstream ? await resolveVerifiedGitRef(cwd, upstream) : null;
} catch {
return null;
}
}
async function resolveInferredDefaultBaseRef(cwd: string) {
const upstream = await resolveGitUpstreamRef(cwd);
if (upstream) return upstream;
const candidates = ["origin/master", "origin/main", "master", "main"];
const resolvedCandidates = await Promise.all(
candidates.map((candidate) => resolveVerifiedGitRef(cwd, candidate)),
);
for (const resolved of resolvedCandidates) {
if (resolved) return resolved;
}
return null;
}
async function resolveDefaultDiffBaseRef(cwd: string, workspace: WorkspaceDiffTarget) {
return workspace.baseRef?.trim() || await resolveInferredDefaultBaseRef(cwd);
}
async function resolveBaseRef(cwd: string, baseRef: string | null, workspace: WorkspaceDiffTarget) {
const resolvedBaseRef = baseRef ?? workspace.baseRef ?? null;
if (!resolvedBaseRef) {
@ -705,9 +756,16 @@ export function workspaceDiffService() {
return {
async getDiff(workspace: WorkspaceDiffTarget, query: WorkspaceDiffQueryOptions): Promise<WorkspaceDiffResponse> {
const { cwd, repoRoot } = await resolveWorkspacePaths(workspace);
const defaultBaseRef = await resolveDefaultDiffBaseRef(cwd, workspace);
const workspaceWithDefaultBaseRef = { ...workspace, baseRef: defaultBaseRef };
const paths = normalizePathFilters(query.paths);
const warnings: WorkspaceDiffWarning[] = [];
const { files: filesByPath, baseRef } = await collectFiles({ cwd, workspace, query, paths });
const { files: filesByPath, baseRef } = await collectFiles({
cwd,
workspace: workspaceWithDefaultBaseRef,
query,
paths,
});
const allFiles = Array.from(filesByPath.values()).sort((left, right) => left.path.localeCompare(right.path));
const cappedFiles = allFiles.slice(0, WORKSPACE_DIFF_CAPS.maxFiles);
if (allFiles.length > cappedFiles.length) {
@ -771,7 +829,7 @@ export function workspaceDiffService() {
companyId: workspace.companyId,
view: query.view,
baseRef,
defaultBaseRef: workspace.baseRef,
defaultBaseRef,
headSha: await resolveHeadSha(cwd),
includeUntracked: query.includeUntracked,
paths,

View file

@ -227,6 +227,115 @@ describe("workspace diff plugin", () => {
});
});
it("uses the primary project workspace default ref when execution workspace has no workspace link", async () => {
const root = await createGitWorkspace();
await git(root, ["checkout", "-b", "feature"]);
await fs.writeFile(path.join(root, "src/app.ts"), "export const value = 5;\n");
await git(root, ["add", "src/app.ts"]);
await git(root, ["commit", "-m", "feature change"]);
const harness = createTestHarness({ manifest });
harness.seed({
executionWorkspaces: [{
id: "workspace-1",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: null,
path: root,
cwd: root,
repoUrl: null,
baseRef: null,
branchName: "feature",
providerType: "git_worktree",
providerMetadata: null,
}],
});
harness.ctx.projects.listWorkspaces = async (projectId, companyId) => {
expect(projectId).toBe("project-1");
expect(companyId).toBe("company-1");
return [{
id: "project-workspace-1",
projectId: "project-1",
name: "Primary",
path: root,
repoUrl: null,
repoRef: "feature",
defaultRef: "main",
isPrimary: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}];
};
await plugin.definition.setup(harness.ctx);
const result = await harness.getData("workspace-diff", {
workspaceId: "workspace-1",
companyId: "company-1",
projectId: "project-1",
view: "head",
baseRef: null,
includeUntracked: false,
});
expect(result).toMatchObject({
baseRef: "main",
defaultBaseRef: "main",
stats: { fileCount: 1 },
files: [expect.objectContaining({ path: "src/app.ts" })],
});
});
it("infers the default base ref from the execution workspace branch upstream", async () => {
const root = await createGitWorkspace();
await git(root, ["update-ref", "refs/remotes/origin/master", "HEAD"]);
await git(root, ["checkout", "-b", "feature"]);
await git(root, ["config", "branch.feature.remote", "origin"]);
await git(root, ["config", "branch.feature.merge", "refs/heads/master"]);
await fs.writeFile(path.join(root, "src/app.ts"), "export const value = 6;\n");
await git(root, ["add", "src/app.ts"]);
await git(root, ["commit", "-m", "feature change"]);
const harness = createTestHarness({ manifest });
harness.seed({
executionWorkspaces: [{
id: "workspace-1",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: null,
path: root,
cwd: root,
repoUrl: null,
baseRef: null,
branchName: "feature",
providerType: "git_worktree",
providerMetadata: null,
}],
});
await plugin.definition.setup(harness.ctx);
await expect(harness.getData("workspace-diff", {
workspaceId: "workspace-1",
companyId: "company-1",
view: "working-tree",
includeUntracked: false,
})).resolves.toMatchObject({
baseRef: null,
defaultBaseRef: "origin/master",
stats: { fileCount: 0 },
});
await expect(harness.getData("workspace-diff", {
workspaceId: "workspace-1",
companyId: "company-1",
view: "head",
baseRef: null,
includeUntracked: false,
})).resolves.toMatchObject({
baseRef: "origin/master",
defaultBaseRef: "origin/master",
stats: { fileCount: 1 },
files: [expect.objectContaining({ path: "src/app.ts" })],
});
});
it("returns a clear bridge error when required context is missing", async () => {
const harness = createTestHarness({ manifest });
await plugin.definition.setup(harness.ctx);

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB