mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
Fix workspace diff default base refs
This commit is contained in:
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);
|
||||
|
||||
Reference in new issue
Block a user