mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 16:35:48 +01:00
Move workspace diff into plugin
This commit is contained in:
1 parent
09ea87fb12
commit
ce6795803e
29 files changed
+161
-1316
No files matched your search
@@ -0,0 +1,142 @@
|
||||
import { z } from "@paperclipai/plugin-sdk";
|
||||
|
||||
export const workspaceDiffViewSchema = z.enum(["working-tree", "head"]);
|
||||
|
||||
export const workspaceDiffFileStatusSchema = z.enum([
|
||||
"added",
|
||||
"modified",
|
||||
"deleted",
|
||||
"renamed",
|
||||
"copied",
|
||||
"type_changed",
|
||||
"untracked",
|
||||
"unknown",
|
||||
]);
|
||||
|
||||
export const workspaceDiffPatchKindSchema = z.enum(["staged", "unstaged", "head", "untracked"]);
|
||||
|
||||
export const workspaceDiffWarningCodeSchema = z.enum([
|
||||
"base_ref_missing",
|
||||
"base_ref_invalid",
|
||||
"binary_file",
|
||||
"file_count_truncated",
|
||||
"file_oversized",
|
||||
"git_command_failed",
|
||||
"missing_cwd",
|
||||
"non_git_workspace",
|
||||
"patch_truncated",
|
||||
"path_filter_invalid",
|
||||
"workspace_path_invalid",
|
||||
]);
|
||||
|
||||
const queryBooleanSchema = z
|
||||
.union([z.boolean(), z.enum(["true", "false"])])
|
||||
.transform((value) => value === true || value === "true");
|
||||
|
||||
function normalizePathQuery(value: unknown): string[] {
|
||||
if (value == null) return [];
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
return values.flatMap((entry) => {
|
||||
if (typeof entry !== "string") return [];
|
||||
return entry
|
||||
.split(",")
|
||||
.map((filePath) => filePath.trim())
|
||||
.filter(Boolean);
|
||||
});
|
||||
}
|
||||
|
||||
export const workspaceDiffQuerySchema = z
|
||||
.object({
|
||||
view: workspaceDiffViewSchema.optional().default("working-tree"),
|
||||
baseRef: z.string().trim().min(1).max(240).optional().nullable(),
|
||||
includeUntracked: queryBooleanSchema.optional().default(true),
|
||||
path: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
paths: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.transform((value) => ({
|
||||
view: value.view,
|
||||
baseRef: value.baseRef?.trim() || null,
|
||||
includeUntracked: value.includeUntracked,
|
||||
paths: normalizePathQuery(value.paths ?? value.path),
|
||||
}));
|
||||
|
||||
export const workspaceDiffWarningSchema = z.object({
|
||||
code: workspaceDiffWarningCodeSchema,
|
||||
message: z.string(),
|
||||
path: z.string().nullable(),
|
||||
}).strict();
|
||||
|
||||
export const workspaceDiffCapsSchema = z.object({
|
||||
maxFiles: z.number().int().positive(),
|
||||
maxFileBytes: z.number().int().positive(),
|
||||
maxPatchBytes: z.number().int().positive(),
|
||||
maxTotalPatchBytes: z.number().int().positive(),
|
||||
}).strict();
|
||||
|
||||
export const workspaceDiffFilePatchSchema = z.object({
|
||||
kind: workspaceDiffPatchKindSchema,
|
||||
patch: z.string().nullable(),
|
||||
additions: z.number().int().nonnegative(),
|
||||
deletions: z.number().int().nonnegative(),
|
||||
binary: z.boolean(),
|
||||
oversized: z.boolean(),
|
||||
truncated: z.boolean(),
|
||||
warnings: z.array(workspaceDiffWarningSchema),
|
||||
}).strict();
|
||||
|
||||
export const workspaceDiffFileSchema = z.object({
|
||||
path: z.string(),
|
||||
oldPath: z.string().nullable(),
|
||||
status: workspaceDiffFileStatusSchema,
|
||||
staged: z.boolean(),
|
||||
unstaged: z.boolean(),
|
||||
untracked: z.boolean(),
|
||||
binary: z.boolean(),
|
||||
oversized: z.boolean(),
|
||||
truncated: z.boolean(),
|
||||
additions: z.number().int().nonnegative(),
|
||||
deletions: z.number().int().nonnegative(),
|
||||
sizeBytes: z.number().int().nonnegative().nullable(),
|
||||
patches: z.array(workspaceDiffFilePatchSchema),
|
||||
warnings: z.array(workspaceDiffWarningSchema),
|
||||
}).strict();
|
||||
|
||||
export const workspaceDiffStatsSchema = z.object({
|
||||
fileCount: z.number().int().nonnegative(),
|
||||
stagedFileCount: z.number().int().nonnegative(),
|
||||
unstagedFileCount: z.number().int().nonnegative(),
|
||||
untrackedFileCount: z.number().int().nonnegative(),
|
||||
binaryFileCount: z.number().int().nonnegative(),
|
||||
oversizedFileCount: z.number().int().nonnegative(),
|
||||
truncatedFileCount: z.number().int().nonnegative(),
|
||||
additions: z.number().int().nonnegative(),
|
||||
deletions: z.number().int().nonnegative(),
|
||||
}).strict();
|
||||
|
||||
export const workspaceDiffResponseSchema = z.object({
|
||||
workspaceId: z.string(),
|
||||
companyId: z.string(),
|
||||
view: workspaceDiffViewSchema,
|
||||
baseRef: z.string().nullable(),
|
||||
headSha: z.string().nullable(),
|
||||
includeUntracked: z.boolean(),
|
||||
paths: z.array(z.string()),
|
||||
files: z.array(workspaceDiffFileSchema),
|
||||
stats: workspaceDiffStatsSchema,
|
||||
warnings: z.array(workspaceDiffWarningSchema),
|
||||
caps: workspaceDiffCapsSchema,
|
||||
truncated: z.boolean(),
|
||||
}).strict();
|
||||
|
||||
export type WorkspaceDiffView = z.infer<typeof workspaceDiffViewSchema>;
|
||||
export type WorkspaceDiffFileStatus = z.infer<typeof workspaceDiffFileStatusSchema>;
|
||||
export type WorkspaceDiffPatchKind = z.infer<typeof workspaceDiffPatchKindSchema>;
|
||||
export type WorkspaceDiffWarningCode = z.infer<typeof workspaceDiffWarningCodeSchema>;
|
||||
export type WorkspaceDiffQueryOptions = z.infer<typeof workspaceDiffQuerySchema>;
|
||||
export type WorkspaceDiffWarning = z.infer<typeof workspaceDiffWarningSchema>;
|
||||
export type WorkspaceDiffCaps = z.infer<typeof workspaceDiffCapsSchema>;
|
||||
export type WorkspaceDiffFilePatch = z.infer<typeof workspaceDiffFilePatchSchema>;
|
||||
export type WorkspaceDiffFile = z.infer<typeof workspaceDiffFileSchema>;
|
||||
export type WorkspaceDiffStats = z.infer<typeof workspaceDiffStatsSchema>;
|
||||
export type WorkspaceDiffResponse = z.infer<typeof workspaceDiffResponseSchema>;
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
WorkspaceDiffFilePatch,
|
||||
WorkspaceDiffResponse,
|
||||
WorkspaceDiffWarning,
|
||||
} from "@paperclipai/plugin-sdk";
|
||||
} from "./contracts.js";
|
||||
|
||||
export type DiffRenderMode = "unified" | "split";
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ const manifest: PaperclipPluginManifestV1 = {
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Workspace Changes",
|
||||
description: "Adds a Changes tab to execution workspaces using Paperclip's host diff API and @pierre/diffs.",
|
||||
description: "Adds a Changes tab to execution and project workspaces using plugin-local Git diff computation and @pierre/diffs.",
|
||||
author: "Paperclip",
|
||||
categories: ["workspace", "ui"],
|
||||
capabilities: [
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui";
|
||||
import { usePluginData, usePluginToast } from "@paperclipai/plugin-sdk/ui";
|
||||
import type { WorkspaceDiffResponse } from "@paperclipai/plugin-sdk";
|
||||
import { DIFFS_TAG_NAME, getSingularPatch } from "@pierre/diffs";
|
||||
import type { PatchDiffProps } from "@pierre/diffs/react";
|
||||
import { useFileDiffInstance } from "@pierre/diffs/react";
|
||||
@@ -16,6 +15,7 @@ import {
|
||||
type DiffPatchViewModel,
|
||||
type DiffRenderMode,
|
||||
} from "../diff-model.js";
|
||||
import type { WorkspaceDiffResponse } from "../contracts.js";
|
||||
|
||||
type WorkspaceDiffData = WorkspaceDiffResponse;
|
||||
type WorkspacePatchDiffOptions = PatchDiffProps<undefined>["options"];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
||||
import type { WorkspaceDiffQueryOptions } from "@paperclipai/plugin-sdk";
|
||||
import { workspaceDiffQuerySchema } from "./contracts.js";
|
||||
import { workspaceDiffService } from "./workspace-diff.js";
|
||||
|
||||
const PLUGIN_NAME = "workspace-diff";
|
||||
@@ -8,29 +8,6 @@ function readString(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function readPaths(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.flatMap((entry) => {
|
||||
if (typeof entry !== "string") return [];
|
||||
const trimmed = entry.trim();
|
||||
return trimmed ? [trimmed] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function readDiffOptions(params: Record<string, unknown>): WorkspaceDiffQueryOptions {
|
||||
const view = params.view === "head" ? "head" : "working-tree";
|
||||
const baseRef = readString(params.baseRef) || null;
|
||||
const includeUntracked = typeof params.includeUntracked === "boolean"
|
||||
? params.includeUntracked
|
||||
: true;
|
||||
return {
|
||||
view,
|
||||
baseRef,
|
||||
includeUntracked,
|
||||
paths: readPaths(params.paths),
|
||||
};
|
||||
}
|
||||
|
||||
const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
ctx.logger.info(`${PLUGIN_NAME} plugin setup`);
|
||||
@@ -57,15 +34,15 @@ const plugin = definePlugin({
|
||||
id: workspace.id,
|
||||
companyId,
|
||||
cwd: workspace.path,
|
||||
baseRef: null,
|
||||
}, readDiffOptions(params));
|
||||
baseRef: workspace.defaultRef ?? workspace.repoRef ?? null,
|
||||
}, workspaceDiffQuerySchema.parse(params));
|
||||
}
|
||||
|
||||
const workspace = await ctx.executionWorkspaces.get(workspaceId, companyId);
|
||||
if (!workspace) {
|
||||
throw new Error("Workspace not found");
|
||||
}
|
||||
return workspaceDiff.getDiff(workspace, readDiffOptions(params));
|
||||
return workspaceDiff.getDiff(workspace, workspaceDiffQuerySchema.parse(params));
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import { execFile } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import type { PluginExecutionWorkspaceMetadata } from "@paperclipai/plugin-sdk";
|
||||
import type {
|
||||
PluginExecutionWorkspaceMetadata,
|
||||
WorkspaceDiffCaps,
|
||||
WorkspaceDiffFile,
|
||||
WorkspaceDiffFilePatch,
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
WorkspaceDiffResponse,
|
||||
WorkspaceDiffWarning,
|
||||
WorkspaceDiffWarningCode,
|
||||
} from "@paperclipai/plugin-sdk";
|
||||
} from "./contracts.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -51,7 +51,7 @@ function warning(code: WorkspaceDiffWarningCode, message: string, filePath: stri
|
||||
|
||||
function workspaceDiffError(code: WorkspaceDiffWarningCode, message: string, details: Record<string, unknown> = {}) {
|
||||
const error = new Error(message);
|
||||
Object.assign(error, { code, details: { code, ...details } });
|
||||
Object.assign(error, { code, status: 422, details: { code, ...details } });
|
||||
return error;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { workspaceDiffQuerySchema, workspaceDiffResponseSchema } from "../src/contracts.js";
|
||||
import { diffResponse } from "./fixtures.js";
|
||||
|
||||
describe("workspace diff plugin contracts", () => {
|
||||
it("normalizes query options from plugin data parameters", () => {
|
||||
expect(workspaceDiffQuerySchema.parse({
|
||||
view: "head",
|
||||
baseRef: " main ",
|
||||
includeUntracked: "false",
|
||||
path: ["src/app.ts, README.md", "packages/shared/src/index.ts"],
|
||||
})).toEqual({
|
||||
view: "head",
|
||||
baseRef: "main",
|
||||
includeUntracked: false,
|
||||
paths: ["src/app.ts", "README.md", "packages/shared/src/index.ts"],
|
||||
});
|
||||
});
|
||||
|
||||
it("validates the plugin-owned response shape", () => {
|
||||
expect(workspaceDiffResponseSchema.parse(diffResponse())).toMatchObject({
|
||||
workspaceId: "11111111-1111-4111-8111-111111111111",
|
||||
stats: { fileCount: 1 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { WorkspaceDiffFile, WorkspaceDiffResponse } from "@paperclipai/plugin-sdk";
|
||||
import type { WorkspaceDiffFile, WorkspaceDiffResponse } from "../src/contracts.js";
|
||||
|
||||
export function changedFile(overrides: Partial<WorkspaceDiffFile> = {}): WorkspaceDiffFile {
|
||||
return {
|
||||
|
||||
@@ -25,6 +25,7 @@ async function createGitWorkspace() {
|
||||
await fs.writeFile(path.join(root, "src/app.ts"), "export const value = 1;\n");
|
||||
await git(root, ["add", "src/app.ts"]);
|
||||
await git(root, ["commit", "-m", "initial"]);
|
||||
await git(root, ["branch", "-M", "main"]);
|
||||
return root;
|
||||
}
|
||||
|
||||
@@ -106,6 +107,47 @@ describe("workspace diff plugin", () => {
|
||||
})).resolves.toMatchObject({ files: [], truncated: false });
|
||||
});
|
||||
|
||||
it("fetches project workspace diffs from generic project workspace metadata", async () => {
|
||||
const root = await createGitWorkspace();
|
||||
await git(root, ["checkout", "-b", "feature"]);
|
||||
await fs.writeFile(path.join(root, "src/app.ts"), "export const value = 3;\n");
|
||||
await git(root, ["add", "src/app.ts"]);
|
||||
await git(root, ["commit", "-m", "project workspace change"]);
|
||||
const harness = createTestHarness({ manifest });
|
||||
harness.ctx.projects.listWorkspaces = async (projectId, companyId) => {
|
||||
expect(projectId).toBe("project-1");
|
||||
expect(companyId).toBe("company-1");
|
||||
return [{
|
||||
id: "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",
|
||||
entityType: "project_workspace",
|
||||
view: "head",
|
||||
includeUntracked: false,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
baseRef: "main",
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { PluginExecutionWorkspaceMetadata } from "@paperclipai/plugin-sdk";
|
||||
import type { WorkspaceDiffQueryOptions } from "../src/contracts.js";
|
||||
import { WORKSPACE_DIFF_CAPS, workspaceDiffService } from "../src/workspace-diff.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const tempDirs = new Set<string>();
|
||||
|
||||
async function runGit(cwd: string, args: string[]) {
|
||||
await execFileAsync("git", ["-C", cwd, ...args], { cwd });
|
||||
}
|
||||
|
||||
async function createTempRepo() {
|
||||
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-workspace-diff-"));
|
||||
tempDirs.add(repoRoot);
|
||||
await runGit(repoRoot, ["init"]);
|
||||
await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]);
|
||||
await runGit(repoRoot, ["config", "user.email", "test@paperclip.local"]);
|
||||
await fs.writeFile(path.join(repoRoot, "tracked-staged.txt"), "alpha\n", "utf8");
|
||||
await fs.writeFile(path.join(repoRoot, "tracked-unstaged.txt"), "bravo\n", "utf8");
|
||||
await fs.writeFile(path.join(repoRoot, "delete-me.txt"), "charlie\n", "utf8");
|
||||
await fs.writeFile(path.join(repoRoot, "rename-me.txt"), "delta\n", "utf8");
|
||||
await fs.writeFile(path.join(repoRoot, "binary.bin"), Buffer.from([0, 1, 2, 3]));
|
||||
await runGit(repoRoot, ["add", "."]);
|
||||
await runGit(repoRoot, ["commit", "-m", "Initial commit"]);
|
||||
await runGit(repoRoot, ["branch", "-M", "main"]);
|
||||
return repoRoot;
|
||||
}
|
||||
|
||||
function createWorkspace(cwd: string | null, overrides: Partial<PluginExecutionWorkspaceMetadata> = {}): PluginExecutionWorkspaceMetadata {
|
||||
return {
|
||||
id: randomUUID(),
|
||||
companyId: randomUUID(),
|
||||
projectId: randomUUID(),
|
||||
projectWorkspaceId: null,
|
||||
path: cwd,
|
||||
cwd,
|
||||
repoUrl: null,
|
||||
baseRef: null,
|
||||
branchName: "feature",
|
||||
providerType: "git_worktree",
|
||||
providerMetadata: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function workingTreeQuery(overrides: Partial<WorkspaceDiffQueryOptions> = {}): WorkspaceDiffQueryOptions {
|
||||
return {
|
||||
view: "working-tree",
|
||||
baseRef: null,
|
||||
includeUntracked: true,
|
||||
paths: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const dir of tempDirs) {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
tempDirs.clear();
|
||||
});
|
||||
|
||||
describe("plugin workspace diff service", () => {
|
||||
it("returns staged, unstaged, renamed, deleted, untracked, binary, and oversized working-tree changes", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
await fs.writeFile(path.join(repoRoot, "tracked-staged.txt"), "alpha\nstaged\n", "utf8");
|
||||
await runGit(repoRoot, ["add", "tracked-staged.txt"]);
|
||||
await fs.writeFile(path.join(repoRoot, "tracked-unstaged.txt"), "bravo\nunstaged\n", "utf8");
|
||||
await runGit(repoRoot, ["mv", "rename-me.txt", "renamed.txt"]);
|
||||
await fs.rm(path.join(repoRoot, "delete-me.txt"));
|
||||
await fs.writeFile(path.join(repoRoot, "binary.bin"), Buffer.from([0, 1, 2, 3, 4, 5]));
|
||||
await fs.writeFile(path.join(repoRoot, "untracked.txt"), "brand new\n", "utf8");
|
||||
await fs.writeFile(path.join(repoRoot, "empty-untracked.txt"), "", "utf8");
|
||||
await fs.writeFile(path.join(repoRoot, "oversized.txt"), "x".repeat(WORKSPACE_DIFF_CAPS.maxFileBytes + 1), "utf8");
|
||||
|
||||
const diff = await workspaceDiffService().getDiff(createWorkspace(repoRoot), workingTreeQuery());
|
||||
const byPath = new Map(diff.files.map((file) => [file.path, file]));
|
||||
|
||||
expect(diff.view).toBe("working-tree");
|
||||
expect(byPath.get("tracked-staged.txt")).toMatchObject({ staged: true, unstaged: false, status: "modified", additions: 1 });
|
||||
expect(byPath.get("tracked-staged.txt")?.patches.map((patch) => patch.kind)).toEqual(["staged"]);
|
||||
expect(byPath.get("tracked-unstaged.txt")).toMatchObject({ staged: false, unstaged: true, status: "modified", additions: 1 });
|
||||
expect(byPath.get("renamed.txt")).toMatchObject({ oldPath: "rename-me.txt", staged: true, status: "renamed" });
|
||||
expect(byPath.get("delete-me.txt")).toMatchObject({ unstaged: true, status: "deleted", deletions: 1 });
|
||||
expect(byPath.get("untracked.txt")).toMatchObject({ untracked: true, status: "untracked", additions: 1 });
|
||||
expect(byPath.get("untracked.txt")?.patches[0]?.patch).toContain("+brand new");
|
||||
expect(byPath.get("empty-untracked.txt")?.patches[0]?.patch).toBe([
|
||||
"diff --git a/empty-untracked.txt b/empty-untracked.txt",
|
||||
"new file mode 100644",
|
||||
"--- /dev/null",
|
||||
"+++ b/empty-untracked.txt",
|
||||
"",
|
||||
].join("\n"));
|
||||
expect(byPath.get("binary.bin")).toMatchObject({ binary: true, unstaged: true });
|
||||
expect(byPath.get("oversized.txt")).toMatchObject({ oversized: true, untracked: true });
|
||||
expect(diff.warnings.map((item) => item.code)).toEqual(expect.arrayContaining(["binary_file", "file_oversized"]));
|
||||
}, 20_000);
|
||||
|
||||
it("returns head diffs against the requested base ref", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
await runGit(repoRoot, ["checkout", "-b", "feature"]);
|
||||
await fs.writeFile(path.join(repoRoot, "tracked-staged.txt"), "alpha\ncommitted\n", "utf8");
|
||||
await runGit(repoRoot, ["add", "tracked-staged.txt"]);
|
||||
await runGit(repoRoot, ["commit", "-m", "Feature change"]);
|
||||
|
||||
const diff = await workspaceDiffService().getDiff(
|
||||
createWorkspace(repoRoot, { baseRef: "main" }),
|
||||
workingTreeQuery({ view: "head", includeUntracked: false }),
|
||||
);
|
||||
|
||||
expect(diff.baseRef).toBe("main");
|
||||
expect(diff.files).toHaveLength(1);
|
||||
expect(diff.files[0]).toMatchObject({
|
||||
path: "tracked-staged.txt",
|
||||
staged: false,
|
||||
unstaged: false,
|
||||
untracked: false,
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
});
|
||||
expect(diff.files[0]?.patches.map((patch) => patch.kind)).toEqual(["head"]);
|
||||
}, 20_000);
|
||||
|
||||
it("filters changed files by relative workspace paths", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
await fs.writeFile(path.join(repoRoot, "tracked-staged.txt"), "alpha\none\n", "utf8");
|
||||
await fs.writeFile(path.join(repoRoot, "tracked-unstaged.txt"), "bravo\ntwo\n", "utf8");
|
||||
|
||||
const diff = await workspaceDiffService().getDiff(
|
||||
createWorkspace(repoRoot),
|
||||
workingTreeQuery({ paths: ["tracked-staged.txt"] }),
|
||||
);
|
||||
|
||||
expect(diff.paths).toEqual(["tracked-staged.txt"]);
|
||||
expect(diff.files.map((file) => file.path)).toEqual(["tracked-staged.txt"]);
|
||||
}, 20_000);
|
||||
|
||||
it("applies output caps to large workspace responses", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
for (let index = 0; index < WORKSPACE_DIFF_CAPS.maxFiles + 1; index += 1) {
|
||||
await fs.writeFile(path.join(repoRoot, `untracked-${String(index).padStart(3, "0")}.txt`), "", "utf8");
|
||||
}
|
||||
|
||||
const diff = await workspaceDiffService().getDiff(createWorkspace(repoRoot), workingTreeQuery());
|
||||
|
||||
expect(diff.files).toHaveLength(WORKSPACE_DIFF_CAPS.maxFiles);
|
||||
expect(diff.truncated).toBe(true);
|
||||
expect(diff.warnings).toContainEqual(expect.objectContaining({ code: "file_count_truncated" }));
|
||||
}, 20_000);
|
||||
|
||||
it("surfaces missing cwd, non-git, invalid base refs, and unsafe path filters as plugin errors", async () => {
|
||||
const svc = workspaceDiffService();
|
||||
await expect(svc.getDiff(createWorkspace(null), workingTreeQuery()))
|
||||
.rejects.toMatchObject({ status: 422, details: { code: "missing_cwd" } });
|
||||
|
||||
const nonGitDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-workspace-diff-non-git-"));
|
||||
tempDirs.add(nonGitDir);
|
||||
await expect(svc.getDiff(createWorkspace(nonGitDir), workingTreeQuery()))
|
||||
.rejects.toMatchObject({ status: 422, details: { code: "non_git_workspace" } });
|
||||
|
||||
const repoRoot = await createTempRepo();
|
||||
await expect(svc.getDiff(createWorkspace(repoRoot), workingTreeQuery({ paths: ["../secret"] })))
|
||||
.rejects.toMatchObject({ status: 422, details: { code: "path_filter_invalid" } });
|
||||
await expect(svc.getDiff(createWorkspace(repoRoot), workingTreeQuery({ view: "head", baseRef: "missing-ref" })))
|
||||
.rejects.toMatchObject({ status: 422, details: { code: "base_ref_invalid" } });
|
||||
}, 20_000);
|
||||
});
|
||||
Reference in new issue
Block a user