diff --git a/packages/plugins/plugin-workspace-diff/src/ui/index.tsx b/packages/plugins/plugin-workspace-diff/src/ui/index.tsx index eaf70f48927..c18765b0a6e 100644 --- a/packages/plugins/plugin-workspace-diff/src/ui/index.tsx +++ b/packages/plugins/plugin-workspace-diff/src/ui/index.tsx @@ -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(() => 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>(() => new Set()); const [selectedPath, setSelectedPath] = useState(null); const fileSectionRefs = useRef(new Map()); const diffScrollRef = useRef(null); const scrollSyncFrameRef = useRef(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("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) {
- -
diff --git a/packages/plugins/plugin-workspace-diff/src/worker.ts b/packages/plugins/plugin-workspace-diff/src/worker.ts index 067af4f107d..d8de9c48303 100644 --- a/packages/plugins/plugin-workspace-diff/src/worker.ts +++ b/packages/plugins/plugin-workspace-diff/src/worker.ts @@ -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 { + 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({ diff --git a/packages/plugins/plugin-workspace-diff/src/workspace-diff.ts b/packages/plugins/plugin-workspace-diff/src/workspace-diff.ts index 3bc08b1533a..0e950dbeaf0 100644 --- a/packages/plugins/plugin-workspace-diff/src/workspace-diff.ts +++ b/packages/plugins/plugin-workspace-diff/src/workspace-diff.ts @@ -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 { 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, diff --git a/packages/plugins/plugin-workspace-diff/tests/plugin.spec.ts b/packages/plugins/plugin-workspace-diff/tests/plugin.spec.ts index 9796c178500..b9b3dd7bc3a 100644 --- a/packages/plugins/plugin-workspace-diff/tests/plugin.spec.ts +++ b/packages/plugins/plugin-workspace-diff/tests/plugin.spec.ts @@ -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);