Fix workspace diff default base refs

This commit is contained in:
Dotta committed 2026-05-19 13:54:54 -05:00
1 parent 858fd027ec
commit d93c771c58
4 files changed
+230 -20

No files matched your search

@@ -57,6 +57,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") ?? "";
@@ -346,22 +351,25 @@ export function ChangesTab({ context }: PluginDetailTabProps) {
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 fileSectionRefs = useRef(new Map<string, HTMLElement>());
const diffScrollRef = useRef<HTMLElement | null>(null);
const scrollSyncFrameRef = useRef<number | null>(null);
const requestedBaseRef = baseRef.trim();
const effectiveView = view === "head" && !requestedBaseRef ? "working-tree" : view;
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]);
@@ -416,9 +424,14 @@ export function ChangesTab({ context }: PluginDetailTabProps) {
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) {
@@ -476,10 +489,26 @@ export function ChangesTab({ context }: PluginDetailTabProps) {
</button>
</div>
<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(view === "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(view === "head")}
onClick={() => {
viewTouchedRef.current = true;
setView("head");
}}
>
Against ref
</button>
</div>
@@ -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({
@@ -645,6 +645,54 @@ 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;
for (const candidate of ["origin/master", "origin/main", "master", "main"]) {
const resolved = await resolveVerifiedGitRef(cwd, candidate);
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 +753,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 +826,7 @@ export function workspaceDiffService() {
companyId: workspace.companyId,
view: query.view,
baseRef,
defaultBaseRef: workspace.baseRef,
defaultBaseRef,
headSha: await resolveHeadSha(cwd),
includeUntracked: query.includeUntracked,
paths,
@@ -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);