mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 09:42:53 +00:00
Review all branch changes by default without automatic scope switching (#7576)
* feat(git): default review to all branch changes Keep the selected comparison stable while agents edit and commit so reviewing the complete result does not require switching scopes or interrupt unfinished comments. * test(git): release Windows checkout handles during cleanup Allow transient filesystem handles from subscription teardown to close before removing the test repository.
This commit is contained in:
@@ -262,24 +262,74 @@ test.afterEach(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("Changes opens the populated committed comparison for a clean checkout", async ({ page }) => {
|
||||
test("Changes opens All changes for a clean checkout", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithCommittedDiff();
|
||||
|
||||
await openWorkspaceChangesSurface(page, workspace, 90_000);
|
||||
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||
await expect(tree.getByTestId("changes-diff-status-trigger")).toContainText("Committed");
|
||||
await expect(tree.getByTestId("changes-diff-status-trigger")).toContainText("All changes");
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
|
||||
});
|
||||
|
||||
test("Changes expires a manual comparison when checkout dirtiness changes", async ({ page }) => {
|
||||
test("All changes combines committed and pending edits and preserves an unfinished review", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithNetDiff();
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await configureDiffPresentation(page, { layout: "unified", wrapLines: false });
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const mode = tree.getByRole("button", { name: "Diff mode", exact: true });
|
||||
await expect(mode).toContainText("All changes");
|
||||
await expectNetComparisonFiles(page, ["a-tracked.ts", "b-committed.ts"]);
|
||||
await expectRenderedAddedLine(page, "committed");
|
||||
|
||||
await test.step("an unfinished review survives the first dirty edit", async () => {
|
||||
await startReviewOnFirstChangedLine(page);
|
||||
await page
|
||||
.getByRole("textbox", { name: "Review comment" })
|
||||
.fill("Unfinished review stays here");
|
||||
await writeFile(path.join(workspace.repoPath, "c-untracked.txt"), "untracked\n");
|
||||
await expectNetComparisonFiles(page, ["a-tracked.ts", "b-committed.ts", "c-untracked.txt"]);
|
||||
await expect(mode).toContainText("All changes");
|
||||
await expect(page.getByRole("textbox", { name: "Review comment" })).toHaveValue(
|
||||
"Unfinished review stays here",
|
||||
);
|
||||
await cancelInlineReview(page);
|
||||
});
|
||||
await test.step("pending edits form a live net patch over the committed edit", async () => {
|
||||
await writeFile(path.join(workspace.repoPath, "a-tracked.ts"), "working\n");
|
||||
await expectRenderedAddedLine(page, "working");
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("a-tracked.ts, +1, -1");
|
||||
});
|
||||
await test.step("committing the same contents preserves the rendered comparison", async () => {
|
||||
execFileSync("git", ["add", "--all"], { cwd: workspace.repoPath });
|
||||
execFileSync("git", ["commit", "-m", "Commit pending changes"], { cwd: workspace.repoPath });
|
||||
await expect(page.getByTestId("changes-primary-cta")).toContainText("Merge", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(mode).toContainText("All changes");
|
||||
await expectNetComparisonFiles(page, ["a-tracked.ts", "b-committed.ts", "c-untracked.txt"]);
|
||||
await expectRenderedAddedLine(page, "working");
|
||||
});
|
||||
await test.step("undoing to the comparison base removes the net change", async () => {
|
||||
await writeFile(path.join(workspace.repoPath, "a-tracked.ts"), "base\n");
|
||||
await expectNetComparisonFiles(page, ["b-committed.ts", "c-untracked.txt"]);
|
||||
await expect(mode).toContainText("All changes");
|
||||
});
|
||||
});
|
||||
|
||||
test("Changes keeps a manual comparison when checkout dirtiness changes", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||
const mode = tree.getByTestId("changes-diff-status-trigger");
|
||||
await expect(mode).toContainText("Uncommitted");
|
||||
await expect(mode).toContainText("All changes");
|
||||
|
||||
await mode.click();
|
||||
await page.getByTestId("changes-diff-mode-committed").click();
|
||||
@@ -294,7 +344,23 @@ test("Changes expires a manual comparison when checkout dirtiness changes", asyn
|
||||
});
|
||||
|
||||
await writeFile(path.join(workspace.repoPath, "new-working-change.txt"), "uncommitted\n");
|
||||
await expect(mode).toContainText("Uncommitted", { timeout: 30_000 });
|
||||
await expect(tree.getByRole("button", { name: "See uncommitted changes" })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(mode).toContainText("Committed");
|
||||
|
||||
await selectChangesComparison(page, "Uncommitted");
|
||||
execFileSync("git", ["add", "--all"], { cwd: workspace.repoPath });
|
||||
execFileSync("git", ["commit", "-m", "Commit new working change"], { cwd: workspace.repoPath });
|
||||
await expect(tree.getByText("No changes to display", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(mode).toContainText("Uncommitted");
|
||||
await writeFile(path.join(workspace.repoPath, "another-working-change.txt"), "pending\n");
|
||||
await expect(tree.getByText("another-working-change.txt", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(mode).toContainText("Uncommitted");
|
||||
});
|
||||
|
||||
test("an empty Changes comparison links to the populated comparison", async ({ page }) => {
|
||||
@@ -633,6 +699,7 @@ test("changes context menus expose folder revert and restore a file after confir
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
await selectChangesComparison(page, "Uncommitted");
|
||||
|
||||
const tree = changesTree(page);
|
||||
await tree.getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||
@@ -680,6 +747,7 @@ test("discarding a staged rename restores its source path", async ({ page }) =>
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeRenamedFile: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
await selectChangesComparison(page, "Uncommitted");
|
||||
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const renamedHeader = diffHeaderForPath(panel, "src/zz-renamed.ts");
|
||||
@@ -710,6 +778,7 @@ test("discarding an untracked file removes it from the working tree", async ({ p
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeUntrackedFile: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
await selectChangesComparison(page, "Uncommitted");
|
||||
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const untrackedHeader = diffHeaderForPath(panel, "zz-untracked.txt");
|
||||
@@ -741,6 +810,7 @@ test("shows a revert error returned by the daemon", async ({ page }) => {
|
||||
await failNextDiscardRequest(page);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
await selectChangesComparison(page, "Uncommitted");
|
||||
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
const confirmation = new Promise<void>((resolve) => {
|
||||
@@ -892,6 +962,8 @@ test("compact Explorer renders inline hunks with a saved tree-only desktop prefe
|
||||
{ key: CHANGES_PREFERENCES_KEY },
|
||||
);
|
||||
const explorer = await openCompactChanges(page, workspace);
|
||||
await explorer.getByRole("button", { name: "Diff mode", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Uncommitted", exact: true }).click();
|
||||
await expect(explorer.getByTestId("git-diff-canvas")).toBeVisible();
|
||||
await expect(explorer.getByTestId("diff-file-0-toggle")).toBeVisible();
|
||||
await explorer.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
@@ -1525,6 +1597,64 @@ async function createWorkspaceWithMountedTabDiff(
|
||||
return { id: createdWorkspace.workspace.id, repoPath: repo.path };
|
||||
}
|
||||
|
||||
async function createWorkspaceWithNetDiff(): Promise<DirtyWorkspace> {
|
||||
const repo = await createTempGitRepo("changes-net-", {
|
||||
files: [{ path: "a-tracked.ts", content: "base\n" }],
|
||||
});
|
||||
const client = await connectSeedClient();
|
||||
cleanupTasks.push({
|
||||
run: async () => {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
},
|
||||
});
|
||||
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repo.path });
|
||||
await writeFile(path.join(repo.path, "a-tracked.ts"), "committed\n");
|
||||
await writeFile(path.join(repo.path, "b-committed.ts"), "committed only\n");
|
||||
execFileSync("git", ["add", "--all"], { cwd: repo.path });
|
||||
execFileSync("git", ["commit", "-m", "Committed changes"], { cwd: repo.path });
|
||||
const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } });
|
||||
if (!created.workspace) throw new Error(created.error ?? "Failed to create net diff workspace");
|
||||
return { id: created.workspace.id, repoPath: repo.path };
|
||||
}
|
||||
|
||||
async function expectNetComparisonFiles(page: Page, files: string[]): Promise<void> {
|
||||
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
await expect(tree.locator('[data-testid^="diff-tree-file-"][data-testid$="-name"]')).toHaveText(
|
||||
files,
|
||||
);
|
||||
await expect(panel.getByTestId(/^diff-file-\d+$/)).toHaveCount(files.length);
|
||||
for (const file of files) {
|
||||
await expect(diffHeaderForPath(panel, file)).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
async function expectRenderedAddedLine(page: Page, contents: string): Promise<void> {
|
||||
await changesTree(page).getByText("a-tracked.ts", { exact: true }).click();
|
||||
await expect(async () => {
|
||||
await page.keyboard.press("Escape");
|
||||
await page.evaluate(() => navigator.clipboard.writeText(""));
|
||||
const body = page.getByTestId("diff-file-0-body");
|
||||
const canvas = page.getByTestId("git-diff-canvas");
|
||||
const bounds = await body.boundingBox();
|
||||
if (!bounds) throw new Error("Expanded diff body has no bounds");
|
||||
const fontSize = await canvas.evaluate((element) =>
|
||||
Number.parseFloat(getComputedStyle(element).fontSize),
|
||||
);
|
||||
// Replacement: hunk header, removed line, added line.
|
||||
await page.mouse.click(bounds.x + 120, bounds.y + Math.round(fontSize * 1.5) * 2.5, {
|
||||
button: "right",
|
||||
});
|
||||
const copyLine = page.getByRole("menuitem", { name: "Copy line", exact: true });
|
||||
await expect(copyLine).toBeEnabled({ timeout: 1000 });
|
||||
await copyLine.click();
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => navigator.clipboard.readText()), { timeout: 1000 })
|
||||
.toBe(contents);
|
||||
}).toPass({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function createWorkspaceWithCommittedDiff(): Promise<DirtyWorkspace> {
|
||||
const repo = await createTempGitRepo("changes-committed-", {
|
||||
files: [{ path: "tracked.ts", content: "export const tracked = 1;\n" }],
|
||||
|
||||
@@ -51,7 +51,7 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
|
||||
emit: <K extends RouterMessageType>(message: Extract<RouterMessage, { type: K }>) => void;
|
||||
subscribeCheckoutDiffCalls: Array<{
|
||||
cwd: string;
|
||||
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
|
||||
compare: { mode: "uncommitted" | "base" | "all"; baseRef?: string; ignoreWhitespace?: boolean };
|
||||
subscriptionId: string;
|
||||
}>;
|
||||
unsubscribeCheckoutDiffCalls: string[];
|
||||
@@ -67,7 +67,7 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
|
||||
};
|
||||
const subscribeCheckoutDiffCalls: Array<{
|
||||
cwd: string;
|
||||
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
|
||||
compare: { mode: "uncommitted" | "base" | "all"; baseRef?: string; ignoreWhitespace?: boolean };
|
||||
subscriptionId: string;
|
||||
}> = [];
|
||||
const unsubscribeCheckoutDiffCalls: string[] = [];
|
||||
@@ -379,12 +379,12 @@ describe("server data push router", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("subscribes active checkout diff queries and writes matching diff events", () => {
|
||||
it.each(["base", "all"] as const)("routes %s diff snapshots and updates", (mode) => {
|
||||
const queryClient = new QueryClient();
|
||||
const fake = createFakeClient();
|
||||
const serverId = "server-1";
|
||||
const cwd = "/repo";
|
||||
const queryKey = checkoutDiffQueryKey(serverId, cwd, "base", "main", true);
|
||||
const queryKey = checkoutDiffQueryKey(serverId, cwd, mode, "main", true);
|
||||
const subscriptionId = `checkoutDiff:${JSON.stringify(queryKey)}`;
|
||||
const observer = new QueryObserver(queryClient, {
|
||||
queryKey,
|
||||
@@ -397,17 +397,18 @@ describe("server data push router", () => {
|
||||
serverId,
|
||||
subscriptionId,
|
||||
cwd,
|
||||
compare: { mode: "base", baseRef: "main", ignoreWhitespace: true },
|
||||
compare: { mode, baseRef: "main", ignoreWhitespace: true },
|
||||
}),
|
||||
});
|
||||
const unsubscribeObserver = observer.subscribe(() => undefined);
|
||||
const unmount = mountServerDataPushRouter({ client: fake.client, queryClient, serverId });
|
||||
|
||||
expect(fake.subscribeCheckoutDiffCalls).toHaveLength(1);
|
||||
const serverSubscriptionId = fake.subscribeCheckoutDiffCalls[0]!.subscriptionId;
|
||||
expect(fake.subscribeCheckoutDiffCalls).toEqual([
|
||||
{
|
||||
cwd,
|
||||
compare: { mode: "base", baseRef: "main", ignoreWhitespace: true },
|
||||
compare: { mode, baseRef: "main", ignoreWhitespace: true },
|
||||
subscriptionId: serverSubscriptionId,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -33,7 +33,7 @@ type CheckoutDiffCachePayload = Omit<CheckoutDiffResponsePayload, "subscriptionI
|
||||
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||
|
||||
interface CheckoutDiffCompare {
|
||||
mode: "uncommitted" | "base";
|
||||
mode: "uncommitted" | "base" | "all";
|
||||
baseRef?: string;
|
||||
ignoreWhitespace?: boolean;
|
||||
}
|
||||
@@ -655,7 +655,7 @@ function readCheckoutDiffCompare(value: unknown): CheckoutDiffCompare | null {
|
||||
const mode = value.mode;
|
||||
const baseRef = value.baseRef;
|
||||
const ignoreWhitespace = value.ignoreWhitespace;
|
||||
if (mode !== "uncommitted" && mode !== "base") {
|
||||
if (mode !== "uncommitted" && mode !== "base" && mode !== "all") {
|
||||
return null;
|
||||
}
|
||||
if (baseRef !== undefined && typeof baseRef !== "string") {
|
||||
|
||||
@@ -92,12 +92,11 @@ function checkoutStatusUpdate(
|
||||
};
|
||||
}
|
||||
|
||||
function selectBaseComparison(isDirtyAtSelection: boolean): void {
|
||||
function selectBaseComparison(): void {
|
||||
selectWorkingDiffComparison({
|
||||
serverId,
|
||||
cwd,
|
||||
comparison: "base",
|
||||
isDirty: isDirtyAtSelection,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -120,14 +119,13 @@ describe("fetchCheckoutStatus", () => {
|
||||
expect(client.getCheckoutStatus).toHaveBeenCalledExactlyOnceWith(cwd);
|
||||
});
|
||||
|
||||
it("expires a manual working-diff comparison when the fetched dirty state flipped", async () => {
|
||||
selectBaseComparison(true);
|
||||
it("keeps a manual working-diff comparison when the fetched dirty state flips", async () => {
|
||||
selectBaseComparison();
|
||||
const client = { getCheckoutStatus: vi.fn(async () => checkoutStatus({ isDirty: false })) };
|
||||
|
||||
await fetchCheckoutStatus({ client, serverId, cwd });
|
||||
|
||||
expect(resolveWorkingDiffComparison({ serverId, cwd, isDirty: false })).toBe("base");
|
||||
expect(resolveWorkingDiffComparison({ serverId, cwd, isDirty: true })).toBe("uncommitted");
|
||||
expect(resolveWorkingDiffComparison({ serverId, cwd })).toBe("base");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -286,31 +284,25 @@ describe("applyCheckoutStatusUpdateFromEvent", () => {
|
||||
).toBe("unauthenticated");
|
||||
});
|
||||
|
||||
it("expires a manual working-diff comparison when the pushed dirty state flipped", () => {
|
||||
const queryClient = createQueryClient();
|
||||
selectBaseComparison(false);
|
||||
|
||||
applyCheckoutStatusUpdateFromEvent({
|
||||
queryClient,
|
||||
serverId,
|
||||
message: checkoutStatusUpdate(checkoutStatus({ isDirty: true })),
|
||||
});
|
||||
|
||||
expect(resolveWorkingDiffComparison({ serverId, cwd, isDirty: true })).toBe("uncommitted");
|
||||
});
|
||||
|
||||
it("keeps a manual working-diff comparison while the pushed dirty state still matches", () => {
|
||||
const queryClient = createQueryClient();
|
||||
selectBaseComparison(true);
|
||||
|
||||
applyCheckoutStatusUpdateFromEvent({
|
||||
queryClient,
|
||||
serverId,
|
||||
message: checkoutStatusUpdate(checkoutStatus({ isDirty: true })),
|
||||
});
|
||||
|
||||
expect(resolveWorkingDiffComparison({ serverId, cwd, isDirty: true })).toBe("base");
|
||||
});
|
||||
it.each(["base", "uncommitted"] as const)(
|
||||
"keeps %s selected across dirty and clean pushes",
|
||||
(comparison) => {
|
||||
const queryClient = createQueryClient();
|
||||
selectWorkingDiffComparison({ serverId, cwd, comparison });
|
||||
applyCheckoutStatusUpdateFromEvent({
|
||||
queryClient,
|
||||
serverId,
|
||||
message: checkoutStatusUpdate(checkoutStatus({ isDirty: true })),
|
||||
});
|
||||
expect(resolveWorkingDiffComparison({ serverId, cwd })).toBe(comparison);
|
||||
applyCheckoutStatusUpdateFromEvent({
|
||||
queryClient,
|
||||
serverId,
|
||||
message: checkoutStatusUpdate(checkoutStatus({ isDirty: false })),
|
||||
});
|
||||
expect(resolveWorkingDiffComparison({ serverId, cwd })).toBe(comparison);
|
||||
},
|
||||
);
|
||||
|
||||
it("invalidates PR detail queries when the prStatus changes, ignoring the volatile requestId", () => {
|
||||
const queryClient = createQueryClient();
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} from "@/git/query-keys";
|
||||
import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status";
|
||||
import { resetDraftAgentCommandsForCheckout } from "@/hooks/agent-commands-query";
|
||||
import { expireWorkingDiffComparisons } from "@/git/working-diff-comparison";
|
||||
|
||||
export type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
||||
export type { CheckoutPrStatusPayload } from "@/git/pr-status";
|
||||
@@ -18,13 +17,8 @@ export interface CheckoutStatusClient {
|
||||
getCheckoutStatus: (cwd: string) => Promise<CheckoutStatusPayload>;
|
||||
}
|
||||
|
||||
// Checkout status enters the app through exactly two doors: daemon pushes
|
||||
// (applyCheckoutStatusUpdateFromEvent) and query fetches (fetchCheckoutStatus). Both run
|
||||
// the dirty-state reactions, so they hold regardless of which screens are mounted.
|
||||
|
||||
export async function fetchCheckoutStatus({
|
||||
client,
|
||||
serverId,
|
||||
cwd,
|
||||
}: {
|
||||
client: CheckoutStatusClient;
|
||||
@@ -32,7 +26,6 @@ export async function fetchCheckoutStatus({
|
||||
cwd: string;
|
||||
}): Promise<CheckoutStatusPayload> {
|
||||
const payload = await client.getCheckoutStatus(cwd);
|
||||
expireWorkingDiffComparisons({ serverId, cwd, isDirty: payload.isGit && payload.isDirty });
|
||||
return payload;
|
||||
}
|
||||
|
||||
@@ -78,11 +71,6 @@ export function applyCheckoutStatusUpdateFromEvent({
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: checkoutCommitsQueryKey(serverId, payload.cwd),
|
||||
});
|
||||
expireWorkingDiffComparisons({
|
||||
serverId,
|
||||
cwd: payload.cwd,
|
||||
isDirty: payload.isGit && payload.isDirty,
|
||||
});
|
||||
|
||||
if (!prStatus) {
|
||||
return;
|
||||
|
||||
@@ -133,7 +133,7 @@ function useDiscardChangesAction({
|
||||
}: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
diffMode: "uncommitted" | "base";
|
||||
diffMode: "uncommitted" | "base" | "all";
|
||||
}): ((path: string, oldPath?: string) => void) | undefined {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
@@ -281,11 +281,12 @@ export function DiffLayoutToggle({
|
||||
}
|
||||
|
||||
interface DiffModeMenuProps {
|
||||
diffMode: "uncommitted" | "base";
|
||||
diffMode: "uncommitted" | "base" | "all";
|
||||
committedDescription?: string;
|
||||
testIDPrefix?: string;
|
||||
onSelectUncommitted: () => void;
|
||||
onSelectBase: () => void;
|
||||
onSelectAll: () => void;
|
||||
}
|
||||
|
||||
export function DiffModeMenu({
|
||||
@@ -294,10 +295,12 @@ export function DiffModeMenu({
|
||||
testIDPrefix = "changes-diff",
|
||||
onSelectUncommitted,
|
||||
onSelectBase,
|
||||
onSelectAll,
|
||||
}: DiffModeMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const uncommittedLabel = t("workspace.git.diff.uncommitted");
|
||||
const committedLabel = t("workspace.git.diff.committed");
|
||||
const allLabel = t("workspace.git.diff.all");
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
@@ -311,7 +314,7 @@ export function DiffModeMenu({
|
||||
return (
|
||||
<>
|
||||
<Text style={toolbarLabelTriggerTextStyle(highlighted)} numberOfLines={1}>
|
||||
{diffMode === "uncommitted" ? uncommittedLabel : committedLabel}
|
||||
{{ all: allLabel, uncommitted: uncommittedLabel, base: committedLabel }[diffMode]}
|
||||
</Text>
|
||||
<ToolbarLabelTriggerIcon>
|
||||
<ThemedChevronDown size={12} uniProps={extraMutedIconColorMapping} />
|
||||
@@ -321,6 +324,14 @@ export function DiffModeMenu({
|
||||
}}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" width={260} testID={`${testIDPrefix}-status-menu`}>
|
||||
<DropdownMenuItem
|
||||
testID={`${testIDPrefix}-mode-all`}
|
||||
selected={diffMode === "all"}
|
||||
onSelect={onSelectAll}
|
||||
>
|
||||
{allLabel}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
testID={`${testIDPrefix}-mode-uncommitted`}
|
||||
selected={diffMode === "uncommitted"}
|
||||
@@ -469,10 +480,11 @@ interface ChangesRepositoryToolbarModel {
|
||||
|
||||
interface ChangesComparisonToolbarModel {
|
||||
committedDescription?: string;
|
||||
diffMode: "uncommitted" | "base";
|
||||
diffMode: "uncommitted" | "base" | "all";
|
||||
mode: ChangesToolbarMode;
|
||||
selectedDiffStat: { additions: number; deletions: number } | null;
|
||||
onSelectBase: () => void;
|
||||
onSelectAll: () => void;
|
||||
onSelectUncommitted: () => void;
|
||||
}
|
||||
|
||||
@@ -488,11 +500,12 @@ interface BuildChangesHeaderModelInput {
|
||||
committedDescription?: string;
|
||||
compact: boolean;
|
||||
cwd: string;
|
||||
diffMode: "uncommitted" | "base";
|
||||
diffMode: "uncommitted" | "base" | "all";
|
||||
gitActions: GitActions;
|
||||
mode: ChangesToolbarMode;
|
||||
onOpenPullRequest: () => void;
|
||||
onSelectBase: () => void;
|
||||
onSelectAll: () => void;
|
||||
onSelectUncommitted: () => void;
|
||||
pullRequest: PrHint | null;
|
||||
selectedDiffStat: { additions: number; deletions: number } | null;
|
||||
@@ -521,6 +534,7 @@ function buildChangesHeaderModel(input: BuildChangesHeaderModelInput): {
|
||||
mode: input.mode,
|
||||
selectedDiffStat: input.selectedDiffStat,
|
||||
onSelectBase: input.onSelectBase,
|
||||
onSelectAll: input.onSelectAll,
|
||||
onSelectUncommitted: input.onSelectUncommitted,
|
||||
},
|
||||
};
|
||||
@@ -737,6 +751,7 @@ function ChangesComparisonToolbar({
|
||||
committedDescription={model.committedDescription}
|
||||
onSelectUncommitted={model.onSelectUncommitted}
|
||||
onSelectBase={model.onSelectBase}
|
||||
onSelectAll={model.onSelectAll}
|
||||
/>
|
||||
{model.selectedDiffStat ? (
|
||||
<DiffStat
|
||||
@@ -1155,7 +1170,7 @@ interface ChangesEmptyAction {
|
||||
|
||||
function computeChangesEmptyAction(input: {
|
||||
hideWhitespace: boolean;
|
||||
diffMode: "uncommitted" | "base";
|
||||
diffMode: "uncommitted" | "base" | "all";
|
||||
status: CheckoutStatusPayload | null;
|
||||
seeUncommittedLabel: string;
|
||||
seeCommittedLabel: string;
|
||||
@@ -1544,6 +1559,7 @@ export function ChangesSurface({
|
||||
diffMode,
|
||||
selectUncommitted: handleSelectUncommitted,
|
||||
selectBase: handleSelectBase,
|
||||
selectAll: handleSelectAll,
|
||||
files,
|
||||
diffPayloadError,
|
||||
diffTooLarge,
|
||||
@@ -1897,6 +1913,7 @@ export function ChangesSurface({
|
||||
mode: toolbarMode,
|
||||
onOpenPullRequest: handleOpenPullRequest,
|
||||
onSelectBase: handleSelectBase,
|
||||
onSelectAll: handleSelectAll,
|
||||
onSelectUncommitted: handleSelectUncommitted,
|
||||
pullRequest: selectPrHintFromStatus(pullRequestStatus, forge),
|
||||
selectedDiffStat,
|
||||
@@ -1911,6 +1928,7 @@ export function ChangesSurface({
|
||||
gitActions,
|
||||
handleOpenPullRequest,
|
||||
handleSelectBase,
|
||||
handleSelectAll,
|
||||
handleSelectUncommitted,
|
||||
isMobile,
|
||||
forge,
|
||||
|
||||
@@ -24,7 +24,7 @@ export function checkoutStatusQueryKey(serverId: string, cwd: string) {
|
||||
export function checkoutDiffQueryKey(
|
||||
serverId: string,
|
||||
cwd: string,
|
||||
mode: "uncommitted" | "base",
|
||||
mode: "uncommitted" | "base" | "all",
|
||||
baseRef?: string,
|
||||
ignoreWhitespace?: boolean,
|
||||
) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { shareCheckoutDiff } from "./diff-sharing";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useMemo } from "react";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useReplicaQuery } from "@/data/query";
|
||||
@@ -10,7 +11,7 @@ import { checkoutDiffQueryKey } from "@/git/query-keys";
|
||||
interface UseCheckoutDiffQueryOptions {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
mode: "uncommitted" | "base";
|
||||
mode: "uncommitted" | "base" | "all";
|
||||
baseRef?: string;
|
||||
ignoreWhitespace?: boolean;
|
||||
enabled?: boolean;
|
||||
@@ -26,18 +27,18 @@ export type DiffLine = DiffHunk["lines"][number];
|
||||
export type HighlightToken = NonNullable<DiffLine["tokens"]>[number];
|
||||
|
||||
function normalizeCheckoutDiffCompare(compare: {
|
||||
mode: "uncommitted" | "base";
|
||||
mode: "uncommitted" | "base" | "all";
|
||||
baseRef?: string;
|
||||
ignoreWhitespace?: boolean;
|
||||
}): { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean } {
|
||||
}): { mode: "uncommitted" | "base" | "all"; baseRef?: string; ignoreWhitespace?: boolean } {
|
||||
const ignoreWhitespace = compare.ignoreWhitespace === true;
|
||||
if (compare.mode === "uncommitted") {
|
||||
return { mode: "uncommitted", ignoreWhitespace };
|
||||
}
|
||||
const trimmedBaseRef = compare.baseRef?.trim();
|
||||
return trimmedBaseRef
|
||||
? { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace }
|
||||
: { mode: "base", ignoreWhitespace };
|
||||
? { mode: compare.mode, baseRef: trimmedBaseRef, ignoreWhitespace }
|
||||
: { mode: compare.mode, ignoreWhitespace };
|
||||
}
|
||||
|
||||
export function useCheckoutDiffQuery({
|
||||
@@ -49,6 +50,11 @@ export function useCheckoutDiffQuery({
|
||||
enabled = true,
|
||||
queryScope,
|
||||
}: UseCheckoutDiffQueryOptions) {
|
||||
// COMPAT(checkoutDiffAll): added in v0.12.0, remove after 2027-04-21 once host floor includes All changes.
|
||||
const allSupported = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.checkoutDiffAll === true,
|
||||
);
|
||||
const requiresUpdate = mode === "all" && !allSupported;
|
||||
const retainedPanelActive = useRetainedPanelActive();
|
||||
const queryEnabled = enabled && retainedPanelActive;
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
@@ -71,7 +77,7 @@ export function useCheckoutDiffQuery({
|
||||
return normalizedScope ? [...comparisonKey, "scope", normalizedScope] : comparisonKey;
|
||||
}, [serverId, cwd, compareMode, compareBaseRef, compareIgnoreWhitespace, queryScope]);
|
||||
const subscriptionId = useMemo(() => `checkoutDiff:${JSON.stringify(queryKey)}`, [queryKey]);
|
||||
const routeEnabled = Boolean(queryEnabled && isConnected && cwd);
|
||||
const routeEnabled = Boolean(queryEnabled && isConnected && cwd && !requiresUpdate);
|
||||
|
||||
const query = useReplicaQuery<CheckoutDiffQueryPayload>({
|
||||
queryKey,
|
||||
@@ -91,7 +97,16 @@ export function useCheckoutDiffQuery({
|
||||
}),
|
||||
});
|
||||
|
||||
return deriveCheckoutDiffResult(query.data ?? null);
|
||||
return deriveCheckoutDiffResult(
|
||||
requiresUpdate && isConnected
|
||||
? {
|
||||
requestId: "unsupported",
|
||||
cwd,
|
||||
files: [],
|
||||
error: { code: "UNKNOWN", message: "Update the host to view All changes" },
|
||||
}
|
||||
: (query.data ?? null),
|
||||
);
|
||||
}
|
||||
|
||||
export interface CheckoutDiffResult {
|
||||
|
||||
@@ -45,7 +45,6 @@ export function useWorkingDiff({
|
||||
status?.error?.message ??
|
||||
(isStatusError && statusError instanceof Error ? statusError.message : null);
|
||||
const baseRef = gitStatus?.baseRef ?? undefined;
|
||||
const hasUncommittedChanges = Boolean(gitStatus?.isDirty);
|
||||
const currentBranchName =
|
||||
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" ? gitStatus.currentBranch : null;
|
||||
|
||||
@@ -53,9 +52,9 @@ export function useWorkingDiff({
|
||||
serverId,
|
||||
workspaceId,
|
||||
cwd,
|
||||
isDirty: hasUncommittedChanges,
|
||||
});
|
||||
const selectUncommitted = useCallback(() => selectComparison("uncommitted"), [selectComparison]);
|
||||
const selectAll = useCallback(() => selectComparison("all"), [selectComparison]);
|
||||
const selectBase = useCallback(() => selectComparison("base"), [selectComparison]);
|
||||
|
||||
const {
|
||||
@@ -111,6 +110,7 @@ export function useWorkingDiff({
|
||||
diffMode,
|
||||
selectUncommitted,
|
||||
selectBase,
|
||||
selectAll,
|
||||
files,
|
||||
diffPayloadError,
|
||||
diffTooLarge,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
import { create } from "zustand";
|
||||
import {
|
||||
expireWorkingDiffComparisonsInState,
|
||||
resolveWorkingDiffComparisonFromState,
|
||||
selectWorkingDiffComparisonInState,
|
||||
type WorkingDiffCheckoutIdentity,
|
||||
@@ -13,7 +12,6 @@ interface WorkingDiffComparisonStore extends WorkingDiffComparisonState {
|
||||
select: (
|
||||
input: WorkingDiffCheckoutIdentity & {
|
||||
comparison: WorkingDiffComparison;
|
||||
isDirty: boolean;
|
||||
},
|
||||
) => void;
|
||||
}
|
||||
@@ -23,21 +21,18 @@ const useWorkingDiffComparisonStore = create<WorkingDiffComparisonStore>((set) =
|
||||
select: (input) => set((state) => selectWorkingDiffComparisonInState(state, input)),
|
||||
}));
|
||||
|
||||
export function useWorkingDiffComparison(
|
||||
input: WorkingDiffCheckoutIdentity & { isDirty: boolean },
|
||||
): {
|
||||
export function useWorkingDiffComparison(input: WorkingDiffCheckoutIdentity): {
|
||||
comparison: WorkingDiffComparison;
|
||||
selectComparison: (comparison: WorkingDiffComparison) => void;
|
||||
} {
|
||||
const { serverId, workspaceId, cwd, isDirty } = input;
|
||||
const { serverId, workspaceId, cwd } = input;
|
||||
const comparison = useWorkingDiffComparisonStore((state) =>
|
||||
resolveWorkingDiffComparisonFromState(state, { serverId, workspaceId, cwd, isDirty }),
|
||||
resolveWorkingDiffComparisonFromState(state, { serverId, workspaceId, cwd }),
|
||||
);
|
||||
const select = useWorkingDiffComparisonStore((state) => state.select);
|
||||
const selectComparison = useCallback(
|
||||
(next: WorkingDiffComparison) =>
|
||||
select({ serverId, workspaceId, cwd, isDirty, comparison: next }),
|
||||
[cwd, isDirty, select, serverId, workspaceId],
|
||||
(next: WorkingDiffComparison) => select({ serverId, workspaceId, cwd, comparison: next }),
|
||||
[cwd, select, serverId, workspaceId],
|
||||
);
|
||||
return { comparison, selectComparison };
|
||||
}
|
||||
@@ -45,28 +40,17 @@ export function useWorkingDiffComparison(
|
||||
export function selectWorkingDiffComparison(
|
||||
input: WorkingDiffCheckoutIdentity & {
|
||||
comparison: WorkingDiffComparison;
|
||||
isDirty: boolean;
|
||||
},
|
||||
): void {
|
||||
useWorkingDiffComparisonStore.getState().select(input);
|
||||
}
|
||||
|
||||
export function resolveWorkingDiffComparison(
|
||||
input: WorkingDiffCheckoutIdentity & { isDirty: boolean },
|
||||
input: WorkingDiffCheckoutIdentity,
|
||||
): WorkingDiffComparison {
|
||||
return resolveWorkingDiffComparisonFromState(useWorkingDiffComparisonStore.getState(), input);
|
||||
}
|
||||
|
||||
export function expireWorkingDiffComparisons(input: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
isDirty: boolean;
|
||||
}): void {
|
||||
useWorkingDiffComparisonStore.setState((state) =>
|
||||
expireWorkingDiffComparisonsInState(state, input),
|
||||
);
|
||||
}
|
||||
|
||||
export function resetWorkingDiffComparisons(): void {
|
||||
useWorkingDiffComparisonStore.setState({ overrides: {} });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
expireWorkingDiffComparisonsInState,
|
||||
resolveWorkingDiffComparisonFromState,
|
||||
selectWorkingDiffComparisonInState,
|
||||
type WorkingDiffComparisonState,
|
||||
@@ -26,79 +25,22 @@ describe("working diff comparison", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults from checkout dirtiness and honors a matching manual selection", () => {
|
||||
expect(
|
||||
resolveWorkingDiffComparisonFromState(emptyState(), { ...checkout, isDirty: true }),
|
||||
).toBe("uncommitted");
|
||||
expect(
|
||||
resolveWorkingDiffComparisonFromState(emptyState(), { ...checkout, isDirty: false }),
|
||||
).toBe("base");
|
||||
|
||||
const selected = selectWorkingDiffComparisonInState(emptyState(), {
|
||||
...checkout,
|
||||
comparison: "base",
|
||||
isDirty: true,
|
||||
});
|
||||
expect(resolveWorkingDiffComparisonFromState(selected, { ...checkout, isDirty: true })).toBe(
|
||||
"base",
|
||||
);
|
||||
it.each([true, false])("defaults to All changes when checkout dirtiness is %s", (isDirty) => {
|
||||
const status = { ...checkout, isDirty };
|
||||
expect(resolveWorkingDiffComparisonFromState(emptyState(), status)).toBe("all");
|
||||
});
|
||||
|
||||
it("masks and expires stale selections for every workspace on the checkout", () => {
|
||||
let state = selectWorkingDiffComparisonInState(emptyState(), {
|
||||
...checkout,
|
||||
comparison: "base",
|
||||
isDirty: true,
|
||||
});
|
||||
state = selectWorkingDiffComparisonInState(state, {
|
||||
...checkout,
|
||||
workspaceId: "workspace-2",
|
||||
comparison: "uncommitted",
|
||||
isDirty: false,
|
||||
});
|
||||
state = selectWorkingDiffComparisonInState(state, {
|
||||
serverId: "server-2",
|
||||
workspaceId: "workspace-3",
|
||||
cwd: "/other",
|
||||
comparison: "base",
|
||||
isDirty: true,
|
||||
});
|
||||
|
||||
expect(resolveWorkingDiffComparisonFromState(state, { ...checkout, isDirty: false })).toBe(
|
||||
"base",
|
||||
);
|
||||
const expired = expireWorkingDiffComparisonsInState(state, {
|
||||
serverId: checkout.serverId,
|
||||
cwd: checkout.cwd,
|
||||
isDirty: false,
|
||||
});
|
||||
expect(expired.overrides[workingDiffComparisonKey(checkout)]).toBeUndefined();
|
||||
expect(
|
||||
expired.overrides[workingDiffComparisonKey({ ...checkout, workspaceId: "workspace-2" })],
|
||||
).toBeDefined();
|
||||
expect(
|
||||
expired.overrides[
|
||||
workingDiffComparisonKey({
|
||||
serverId: "server-2",
|
||||
workspaceId: "workspace-3",
|
||||
cwd: "/other",
|
||||
})
|
||||
],
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("keeps state identity when nothing expires", () => {
|
||||
const state = selectWorkingDiffComparisonInState(emptyState(), {
|
||||
...checkout,
|
||||
comparison: "base",
|
||||
isDirty: true,
|
||||
});
|
||||
expect(
|
||||
expireWorkingDiffComparisonsInState(state, {
|
||||
serverId: checkout.serverId,
|
||||
cwd: checkout.cwd,
|
||||
isDirty: true,
|
||||
}),
|
||||
).toBe(state);
|
||||
});
|
||||
it.each(["all", "base", "uncommitted"] as const)(
|
||||
"keeps the selected %s comparison",
|
||||
(comparison) => {
|
||||
const selected = selectWorkingDiffComparisonInState(emptyState(), {
|
||||
...checkout,
|
||||
comparison,
|
||||
});
|
||||
expect(resolveWorkingDiffComparisonFromState(selected, checkout)).toBe(comparison);
|
||||
expect(
|
||||
resolveWorkingDiffComparisonFromState(selected, { ...checkout, workspaceId: "other" }),
|
||||
).toBe("all");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
export type WorkingDiffComparison = "uncommitted" | "base";
|
||||
export type WorkingDiffComparison = "uncommitted" | "base" | "all";
|
||||
|
||||
export interface WorkingDiffComparisonOverride {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
comparison: WorkingDiffComparison;
|
||||
isDirtyAtSelection: boolean;
|
||||
}
|
||||
|
||||
export interface WorkingDiffComparisonState {
|
||||
@@ -34,17 +31,13 @@ export function selectWorkingDiffComparisonInState(
|
||||
state: WorkingDiffComparisonState,
|
||||
input: WorkingDiffCheckoutIdentity & {
|
||||
comparison: WorkingDiffComparison;
|
||||
isDirty: boolean;
|
||||
},
|
||||
): WorkingDiffComparisonState {
|
||||
return {
|
||||
overrides: {
|
||||
...state.overrides,
|
||||
[workingDiffComparisonKey(input)]: {
|
||||
serverId: input.serverId.trim(),
|
||||
cwd: normalizeCwd(input.cwd),
|
||||
comparison: input.comparison,
|
||||
isDirtyAtSelection: input.isDirty,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -52,34 +45,7 @@ export function selectWorkingDiffComparisonInState(
|
||||
|
||||
export function resolveWorkingDiffComparisonFromState(
|
||||
state: WorkingDiffComparisonState,
|
||||
input: WorkingDiffCheckoutIdentity & { isDirty: boolean },
|
||||
input: WorkingDiffCheckoutIdentity,
|
||||
): WorkingDiffComparison {
|
||||
const override = state.overrides[workingDiffComparisonKey(input)];
|
||||
// Status can render before boundary expiry runs, so resolution must also mask a stale
|
||||
// selection under any ordering of the two updates.
|
||||
if (override?.isDirtyAtSelection === input.isDirty) {
|
||||
return override.comparison;
|
||||
}
|
||||
return input.isDirty ? "uncommitted" : "base";
|
||||
}
|
||||
|
||||
export function expireWorkingDiffComparisonsInState(
|
||||
state: WorkingDiffComparisonState,
|
||||
input: { serverId: string; cwd: string; isDirty: boolean },
|
||||
): WorkingDiffComparisonState {
|
||||
// Expire at the status boundary so selections cannot return after an unmounted surface
|
||||
// misses a dirty-state transition.
|
||||
const staleKeys = Object.entries(state.overrides)
|
||||
.filter(
|
||||
([, override]) =>
|
||||
override.serverId === input.serverId.trim() &&
|
||||
override.cwd === normalizeCwd(input.cwd) &&
|
||||
override.isDirtyAtSelection !== input.isDirty,
|
||||
)
|
||||
.map(([key]) => key);
|
||||
if (staleKeys.length === 0) return state;
|
||||
|
||||
const overrides = { ...state.overrides };
|
||||
for (const key of staleKeys) delete overrides[key];
|
||||
return { overrides };
|
||||
return state.overrides[workingDiffComparisonKey(input)]?.comparison ?? "all";
|
||||
}
|
||||
|
||||
@@ -956,6 +956,7 @@ export const ar: TranslationResources = {
|
||||
checkingRepository: "فحص المستودع...",
|
||||
notRepository: "ليس مستودع جيت",
|
||||
diffMode: "وضع الفرق",
|
||||
all: "كل التغييرات",
|
||||
uncommitted: "غير ملتزم",
|
||||
committed: "ملتزم",
|
||||
branchUnknown: "مجهول",
|
||||
|
||||
@@ -964,6 +964,7 @@ export const en = {
|
||||
checkingRepository: "Checking repository...",
|
||||
notRepository: "Not a git repository",
|
||||
diffMode: "Diff mode",
|
||||
all: "All changes",
|
||||
uncommitted: "Uncommitted",
|
||||
committed: "Committed",
|
||||
branchUnknown: "Unknown",
|
||||
|
||||
@@ -989,6 +989,7 @@ export const es: TranslationResources = {
|
||||
checkingRepository: "Comprobando repositorio...",
|
||||
notRepository: "No es un repositorio de git",
|
||||
diffMode: "modo diferencial",
|
||||
all: "Todos los cambios",
|
||||
uncommitted: "No comprometido",
|
||||
committed: "Comprometido",
|
||||
branchUnknown: "Desconocido",
|
||||
|
||||
@@ -982,6 +982,7 @@ export const fr: TranslationResources = {
|
||||
checkingRepository: "Vérification du dépôt…",
|
||||
notRepository: "Ce n’est pas un dépôt git",
|
||||
diffMode: "Mode de diff",
|
||||
all: "Toutes les modifications",
|
||||
uncommitted: "Non commité",
|
||||
committed: "Commité",
|
||||
branchUnknown: "Inconnue",
|
||||
|
||||
@@ -969,6 +969,7 @@ export const ja: TranslationResources = {
|
||||
checkingRepository: "リポジトリを確認中...",
|
||||
notRepository: "gitリポジトリではありません",
|
||||
diffMode: "差分モード",
|
||||
all: "すべての変更",
|
||||
uncommitted: "未コミット",
|
||||
committed: "コミット済み",
|
||||
branchUnknown: "不明",
|
||||
|
||||
@@ -963,6 +963,7 @@ export const ko: TranslationResources = {
|
||||
checkingRepository: "저장소 확인 중...",
|
||||
notRepository: "Git 저장소가 아닙니다",
|
||||
diffMode: "Diff 모드",
|
||||
all: "모든 변경 사항",
|
||||
uncommitted: "커밋되지 않음",
|
||||
committed: "커밋됨",
|
||||
branchUnknown: "알 수 없음",
|
||||
|
||||
@@ -979,6 +979,7 @@ export const ptBR: TranslationResources = {
|
||||
checkingRepository: "Verificando repositório...",
|
||||
notRepository: "Não é um repositório git",
|
||||
diffMode: "Modo de diff",
|
||||
all: "Todas as alterações",
|
||||
uncommitted: "Sem commit",
|
||||
committed: "Com commit",
|
||||
branchUnknown: "Desconhecido",
|
||||
|
||||
@@ -973,6 +973,7 @@ export const ru: TranslationResources = {
|
||||
checkingRepository: "Проверяем репозиторий...",
|
||||
notRepository: "Это не репозиторий Git",
|
||||
diffMode: "Режим диффа",
|
||||
all: "Все изменения",
|
||||
uncommitted: "Незафиксированные изменения",
|
||||
committed: "Зафиксированные изменения",
|
||||
branchUnknown: "Неизвестная ветка",
|
||||
|
||||
@@ -948,6 +948,7 @@ export const zhCN: TranslationResources = {
|
||||
checkingRepository: "正在检查 repository...",
|
||||
notRepository: "不是 git repository",
|
||||
diffMode: "Diff 模式",
|
||||
all: "全部更改",
|
||||
uncommitted: "未 commit",
|
||||
committed: "已 commit",
|
||||
branchUnknown: "未知",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export type ReviewDraftMode = "uncommitted" | "base";
|
||||
export type ReviewDraftMode = "uncommitted" | "base" | "all";
|
||||
export type ReviewDraftSide = "old" | "new";
|
||||
|
||||
export interface ReviewDraftComment {
|
||||
|
||||
@@ -4395,10 +4395,10 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
private normalizeCheckoutDiffCompare(compare: {
|
||||
mode: "uncommitted" | "base";
|
||||
mode: "uncommitted" | "base" | "all";
|
||||
baseRef?: string;
|
||||
ignoreWhitespace?: boolean;
|
||||
}): { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean } {
|
||||
}): { mode: "uncommitted" | "base" | "all"; baseRef?: string; ignoreWhitespace?: boolean } {
|
||||
if (compare.mode === "uncommitted") {
|
||||
return compare.ignoreWhitespace === true
|
||||
? { mode: "uncommitted", ignoreWhitespace: true }
|
||||
@@ -4407,17 +4407,17 @@ export class DaemonClient {
|
||||
const trimmedBaseRef = compare.baseRef?.trim();
|
||||
if (!trimmedBaseRef) {
|
||||
return compare.ignoreWhitespace === true
|
||||
? { mode: "base", ignoreWhitespace: true }
|
||||
: { mode: "base" };
|
||||
? { mode: compare.mode, ignoreWhitespace: true }
|
||||
: { mode: compare.mode };
|
||||
}
|
||||
return compare.ignoreWhitespace === true
|
||||
? { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace: true }
|
||||
: { mode: "base", baseRef: trimmedBaseRef };
|
||||
? { mode: compare.mode, baseRef: trimmedBaseRef, ignoreWhitespace: true }
|
||||
: { mode: compare.mode, baseRef: trimmedBaseRef };
|
||||
}
|
||||
|
||||
async getCheckoutDiff(
|
||||
cwd: string,
|
||||
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean },
|
||||
compare: { mode: "uncommitted" | "base" | "all"; baseRef?: string; ignoreWhitespace?: boolean },
|
||||
requestId?: string,
|
||||
): Promise<CheckoutDiffPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
@@ -4433,7 +4433,7 @@ export class DaemonClient {
|
||||
|
||||
observeCheckoutDiff(
|
||||
cwd: string,
|
||||
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean },
|
||||
compare: { mode: "uncommitted" | "base" | "all"; baseRef?: string; ignoreWhitespace?: boolean },
|
||||
options?: { requestId?: string; signal?: AbortSignal },
|
||||
): OwnedSubscription<SubscribeCheckoutDiffPayload> {
|
||||
return this.observe(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
AgentTimelineItemPayloadSchema,
|
||||
SendAgentMessageRequestSchema,
|
||||
FileExplorerRequestSchema,
|
||||
PaseoWorktreeArchiveRequestSchema,
|
||||
parseServerInfoStatusPayload,
|
||||
@@ -548,3 +549,25 @@ test("agent messages are an optional semantic payload on existing tool rows", ()
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test.each(["all", "base", "uncommitted"] as const)(
|
||||
"preserves %s review attachments while ignoring unknown attachments",
|
||||
(mode) => {
|
||||
const attachment = {
|
||||
type: "review",
|
||||
mimeType: "application/paseo-review",
|
||||
cwd: "/repo",
|
||||
mode,
|
||||
baseRef: "main",
|
||||
comments: [],
|
||||
};
|
||||
const parsed = SendAgentMessageRequestSchema.parse({
|
||||
type: "send_agent_message_request",
|
||||
requestId: "review",
|
||||
agentId: "agent",
|
||||
text: "Review",
|
||||
attachments: [{ type: "future_attachment" }, attachment],
|
||||
});
|
||||
expect(parsed.attachments).toEqual([attachment]);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1168,7 +1168,7 @@ export const ReviewAttachmentSchema = z.object({
|
||||
type: z.literal("review"),
|
||||
mimeType: z.literal("application/paseo-review"),
|
||||
cwd: z.string(),
|
||||
mode: z.enum(["uncommitted", "base"]),
|
||||
mode: z.enum(["uncommitted", "base", "all"]),
|
||||
baseRef: z.string().nullable().optional(),
|
||||
comments: z.array(ReviewAttachmentCommentSchema),
|
||||
});
|
||||
@@ -2201,7 +2201,7 @@ const CheckoutErrorSchema = z.object({
|
||||
});
|
||||
|
||||
const CheckoutDiffCompareSchema = z.object({
|
||||
mode: z.enum(["uncommitted", "base"]),
|
||||
mode: z.enum(["uncommitted", "base", "all"]),
|
||||
baseRef: z.string().optional(),
|
||||
ignoreWhitespace: z.boolean().optional(),
|
||||
});
|
||||
@@ -3793,6 +3793,7 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
fsEntryDuplicate: z.boolean().optional(),
|
||||
// COMPAT(checkoutDiscardChanges): added in v0.3.0, remove gate after 2027-02-08.
|
||||
checkoutDiscardChanges: z.boolean().optional(),
|
||||
checkoutDiffAll: z.boolean().optional(),
|
||||
// COMPAT(agentProfiles): added in v0.3.2, remove gate after 2027-02-11.
|
||||
// An older daemon parses its persisted config strictly, so writing
|
||||
// agentProfiles to one is silently dropped. The client hides the feature
|
||||
|
||||
@@ -69,13 +69,13 @@ describe("prompt attachments", () => {
|
||||
).toContain("GitLab MR !123: Fix race in worktree setup");
|
||||
});
|
||||
|
||||
it("renders review attachments with compact file, line, comment, and context details", () => {
|
||||
it.each(["base", "uncommitted", "all"] as const)("renders %s reviews", (mode) => {
|
||||
expect(
|
||||
renderPromptAttachmentAsText({
|
||||
type: "review",
|
||||
mimeType: "application/paseo-review",
|
||||
cwd: "/tmp/repo",
|
||||
mode: "base",
|
||||
mode,
|
||||
baseRef: "main",
|
||||
comments: [
|
||||
{
|
||||
@@ -111,7 +111,7 @@ describe("prompt attachments", () => {
|
||||
}),
|
||||
).toBe(
|
||||
[
|
||||
"Paseo review attachment (base)",
|
||||
`Paseo review attachment (${mode})`,
|
||||
"CWD: /tmp/repo",
|
||||
"Base: main",
|
||||
"",
|
||||
|
||||
@@ -114,3 +114,21 @@ test("active reads survive completed-payload eviction and failed reads can retry
|
||||
}),
|
||||
).toEqual({ diff: "retry" });
|
||||
});
|
||||
|
||||
test.each(["base", "uncommitted"] as const)(
|
||||
"%s invalidation refreshes All changes without merging base-ref cache entries",
|
||||
async (mode) => {
|
||||
const cache = new CheckoutDiffCache(() => 0);
|
||||
const read = (baseRef: string, value: string) =>
|
||||
cache.read({
|
||||
cwd: "repo",
|
||||
compare: { mode: "all", baseRef },
|
||||
load: async () => ({ diff: value }),
|
||||
});
|
||||
expect(await read("main", "main-before")).toEqual({ diff: "main-before" });
|
||||
expect(await read("next", "next-before")).toEqual({ diff: "next-before" });
|
||||
cache.invalidate("repo", mode);
|
||||
expect(await read("main", "main-after")).toEqual({ diff: "main-after" });
|
||||
expect(await read("next", "next-after")).toEqual({ diff: "next-after" });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -35,7 +35,7 @@ export class CheckoutDiffCache {
|
||||
const key = JSON.stringify([
|
||||
cwd,
|
||||
compare.mode,
|
||||
compare.mode === "base" ? (compare.baseRef ?? null) : null,
|
||||
compare.mode !== "uncommitted" ? (compare.baseRef ?? null) : null,
|
||||
compare.ignoreWhitespace === true,
|
||||
compare.includeStructured === true,
|
||||
]);
|
||||
@@ -83,7 +83,7 @@ export class CheckoutDiffCache {
|
||||
invalidate(cwd: string, mode: CheckoutDiffCompare["mode"]): void {
|
||||
for (const key of new Set([...this.values.keys(), ...this.active.keys()])) {
|
||||
const [cachedCwd, cachedMode] = JSON.parse(key) as string[];
|
||||
if (cachedCwd !== cwd || cachedMode !== mode) continue;
|
||||
if (cachedCwd !== cwd || (cachedMode !== mode && cachedMode !== "all")) continue;
|
||||
this.values.delete(key);
|
||||
const pending = this.active.get(key);
|
||||
if (pending) pending.invalidated = true;
|
||||
|
||||
@@ -158,15 +158,15 @@ export class CheckoutDiffManager {
|
||||
}
|
||||
const trimmedBaseRef = compare.baseRef?.trim();
|
||||
return trimmedBaseRef
|
||||
? { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace }
|
||||
: { mode: "base", ignoreWhitespace };
|
||||
? { mode: compare.mode, baseRef: trimmedBaseRef, ignoreWhitespace }
|
||||
: { mode: compare.mode, ignoreWhitespace };
|
||||
}
|
||||
|
||||
private buildTargetKey(cwd: string, compare: CheckoutDiffCompareInput): string {
|
||||
return JSON.stringify([
|
||||
cwd,
|
||||
compare.mode,
|
||||
compare.mode === "base" ? (compare.baseRef ?? "") : "",
|
||||
compare.mode !== "uncommitted" ? (compare.baseRef ?? "") : "",
|
||||
compare.ignoreWhitespace === true,
|
||||
]);
|
||||
}
|
||||
@@ -364,7 +364,7 @@ export class CheckoutDiffManager {
|
||||
}
|
||||
|
||||
private async openTarget(target: CheckoutDiffWatchTarget): Promise<void> {
|
||||
if (target.compare.mode === "base") {
|
||||
if (target.compare.mode !== "uncommitted") {
|
||||
const snapshot =
|
||||
this.workspaceGitService.peekSnapshot(target.cwd) ??
|
||||
(await this.workspaceGitService.getSnapshot(target.cwd, { includeForge: false }));
|
||||
@@ -382,7 +382,7 @@ export class CheckoutDiffManager {
|
||||
return;
|
||||
}
|
||||
target.workspaceGitUnsubscribe = workspaceSubscription.unsubscribe;
|
||||
return;
|
||||
if (target.compare.mode === "base") return;
|
||||
}
|
||||
|
||||
const { repoRoot, unsubscribe } = await this.workspaceGitService.requestWorkingTreeWatch(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { expect, test } from "vitest";
|
||||
@@ -158,3 +159,67 @@ test.each(
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
test("All changes subscriptions refresh on file edits and commits", async () => {
|
||||
const ctx = await createDaemonTestContext();
|
||||
const cwd = mkdtempSync(join(tmpdir(), "checkout-diff-all-"));
|
||||
try {
|
||||
git(cwd, "init", "-b", "main");
|
||||
git(cwd, "config", "user.name", "Test");
|
||||
git(cwd, "config", "user.email", "test@example.com");
|
||||
writeFileSync(join(cwd, "file.txt"), "base\n");
|
||||
git(cwd, "add", ".");
|
||||
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "base");
|
||||
git(cwd, "checkout", "-b", "feature");
|
||||
writeFileSync(join(cwd, "file.txt"), "committed\n");
|
||||
git(cwd, "add", ".");
|
||||
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "feature");
|
||||
const subscription = ctx.client.observeCheckoutDiff(cwd, { mode: "all", baseRef: "main" });
|
||||
try {
|
||||
let latest = await subscription.ready;
|
||||
expect(latest.error).toBeNull();
|
||||
subscription.subscribe({
|
||||
snapshot: (snapshot) => {
|
||||
latest = snapshot;
|
||||
},
|
||||
update: (message) => {
|
||||
if (message.type === "checkout_diff_update") latest = message.payload;
|
||||
},
|
||||
});
|
||||
writeFileSync(join(cwd, "file.txt"), "working\n");
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
latest.files[0]?.hunks
|
||||
.flatMap((hunk) => hunk.lines)
|
||||
.filter((line) => line.type === "add")
|
||||
.map((line) => line.content),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toEqual(["working"]);
|
||||
writeFileSync(join(cwd, "new.txt"), "untracked\n");
|
||||
await expect
|
||||
.poll(() => latest.files.map((file) => file.path), { timeout: 15_000 })
|
||||
.toEqual(["file.txt", "new.txt"]);
|
||||
const beforeCommit = latest.files;
|
||||
git(cwd, "add", ".");
|
||||
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "working");
|
||||
expect(
|
||||
(await ctx.client.getCheckoutDiff(cwd, { mode: "all", baseRef: "main" })).files,
|
||||
).toEqual(beforeCommit);
|
||||
writeFileSync(join(cwd, "file.txt"), "base\n");
|
||||
await expect
|
||||
.poll(() => latest.files.map((file) => file.path), { timeout: 15_000 })
|
||||
.toEqual(["new.txt"]);
|
||||
git(cwd, "checkout", "--", "file.txt");
|
||||
await expect
|
||||
.poll(() => latest.files.map((file) => file.path), { timeout: 15_000 })
|
||||
.toEqual(["file.txt", "new.txt"]);
|
||||
} finally {
|
||||
await subscription.release();
|
||||
}
|
||||
} finally {
|
||||
await ctx.cleanup();
|
||||
await rm(cwd, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
@@ -2096,6 +2096,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
fsEntryDuplicate: true,
|
||||
// COMPAT(checkoutDiscardChanges): added in v0.3.0, remove gate after 2027-02-08.
|
||||
checkoutDiscardChanges: true,
|
||||
checkoutDiffAll: true,
|
||||
// COMPAT(agentProfiles): added in v0.3.2, remove gate after 2027-02-11.
|
||||
agentProfiles: true,
|
||||
// COMPAT(agentConfigApply): added in v0.3.2, remove gate after 2027-02-11.
|
||||
|
||||
@@ -827,7 +827,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
private normalizeCheckoutDiffOptions(options: CheckoutDiffCompare): CheckoutDiffCompare {
|
||||
return {
|
||||
mode: options.mode,
|
||||
...(options.mode === "base" && options.baseRef !== undefined
|
||||
...(options.mode !== "uncommitted" && options.baseRef !== undefined
|
||||
? { baseRef: options.baseRef }
|
||||
: {}),
|
||||
...(options.ignoreWhitespace === true ? { ignoreWhitespace: true } : {}),
|
||||
|
||||
@@ -1398,6 +1398,92 @@ const x = 1;
|
||||
expect(diff.structured).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ contents: "hello\n", expectedFiles: [], patch: "" },
|
||||
{
|
||||
contents: "restored\n",
|
||||
expectedFiles: [
|
||||
{ path: "file.txt", isNew: false, isDeleted: false, additions: 1, deletions: 1 },
|
||||
],
|
||||
patch: "-hello\n+restored\n",
|
||||
},
|
||||
])(
|
||||
"compares restored untracked base files against disk: $contents",
|
||||
async ({ contents, expectedFiles, patch }) => {
|
||||
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
|
||||
execFileSync("git", ["rm", "file.txt"], { cwd: repoDir });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "delete"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
writeFileSync(join(repoDir, "file.txt"), contents);
|
||||
const indexPath = execFileSync("git", ["rev-parse", "--git-path", "index"], {
|
||||
cwd: repoDir,
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
const beforeIndex = readFileSync(join(repoDir, indexPath));
|
||||
const beforeStatus = execFileSync("git", ["status", "--porcelain=v1", "-z"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
const result = await getCheckoutDiff(repoDir, {
|
||||
mode: "all",
|
||||
baseRef: "main",
|
||||
includeStructured: true,
|
||||
});
|
||||
expect(
|
||||
result.structured?.map(({ path, isNew, isDeleted, additions, deletions }) => ({
|
||||
path,
|
||||
isNew,
|
||||
isDeleted,
|
||||
additions,
|
||||
deletions,
|
||||
})),
|
||||
).toEqual(expectedFiles);
|
||||
expect(result.diff).toContain(patch);
|
||||
expect(result.diff.length === 0).toBe(expectedFiles.length === 0);
|
||||
expect(readFileSync(join(repoDir, indexPath))).toEqual(beforeIndex);
|
||||
expect(execFileSync("git", ["status", "--porcelain=v1", "-z"], { cwd: repoDir })).toEqual(
|
||||
beforeStatus,
|
||||
);
|
||||
expect(readFileSync(join(repoDir, "file.txt"), "utf8")).toBe(contents);
|
||||
expect((await getCheckoutDiff(repoDir, { mode: "base", baseRef: "main" })).diff).toContain(
|
||||
"deleted file mode",
|
||||
);
|
||||
expect((await getCheckoutDiff(repoDir, { mode: "uncommitted" })).diff).toContain(
|
||||
"new file mode",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("compares all changes as one net patch including untracked files", async () => {
|
||||
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
|
||||
writeFileSync(join(repoDir, "file.txt"), "committed\n");
|
||||
execFileSync("git", ["add", "."], { cwd: repoDir });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
writeFileSync(join(repoDir, "file.txt"), "working\n");
|
||||
writeFileSync(join(repoDir, "new.txt"), "untracked\n");
|
||||
const compare = { mode: "all", baseRef: "main", includeStructured: true } as const;
|
||||
const before = await getCheckoutDiff(repoDir, compare);
|
||||
expect(before.structured?.map((file) => file.path)).toEqual(["file.txt", "new.txt"]);
|
||||
expect(before.diff).toContain("-hello\n+working\n");
|
||||
expect(before.diff).not.toContain("committed");
|
||||
expect(before.diff).toContain("+untracked");
|
||||
const committed = await getCheckoutDiff(repoDir, { ...compare, mode: "base" });
|
||||
expect(committed.diff).toContain("-hello\n+committed\n");
|
||||
const uncommitted = await getCheckoutDiff(repoDir, { ...compare, mode: "uncommitted" });
|
||||
expect(uncommitted.diff).toContain("-committed\n+working\n");
|
||||
execFileSync("git", ["add", "."], { cwd: repoDir });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "working"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
expect((await getCheckoutDiff(repoDir, compare)).diff).toBe(before.diff);
|
||||
writeFileSync(join(repoDir, "file.txt"), "hello\n");
|
||||
expect((await getCheckoutDiff(repoDir, compare)).structured?.map((file) => file.path)).toEqual([
|
||||
"new.txt",
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to the local base branch when origin is absent", async () => {
|
||||
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
|
||||
writeFileSync(join(repoDir, "local-feature.txt"), "feature\n");
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { MAX_RELAY_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit";
|
||||
import { resolve, dirname, basename } from "path";
|
||||
import { existsSync, realpathSync } from "fs";
|
||||
import { open as openFile, readFile, stat as statFile } from "fs/promises";
|
||||
import { open as openFile, readFile, stat as statFile, mkdtemp, copyFile, rm } from "fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import { TTLCache } from "@isaacs/ttlcache";
|
||||
import type { CheckoutCommit, CheckoutCommitFile } from "@getpaseo/protocol/messages";
|
||||
@@ -203,6 +204,7 @@ interface CheckoutFileChange {
|
||||
}
|
||||
|
||||
interface CheckoutDiffRefs {
|
||||
indexEnv?: { GIT_INDEX_FILE: string };
|
||||
baseRef: string;
|
||||
targetRef?: string;
|
||||
includeUntracked: boolean;
|
||||
@@ -518,7 +520,7 @@ async function listCheckoutFileChanges(
|
||||
ignoreWhitespace,
|
||||
extra: ["-z", "--name-status", ...getCheckoutDiffRefArgs(refs)],
|
||||
}),
|
||||
{ cwd, envOverlay: READ_ONLY_GIT_ENV },
|
||||
{ cwd, envOverlay: { ...READ_ONLY_GIT_ENV, ...refs.indexEnv } },
|
||||
);
|
||||
const nameStatusFields = nameStatusOut.split("\0");
|
||||
for (let index = 0; index < nameStatusFields.length; ) {
|
||||
@@ -556,7 +558,7 @@ async function listCheckoutFileChanges(
|
||||
["ls-files", "-z", "--others", "--exclude-standard"],
|
||||
{
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
envOverlay: { ...READ_ONLY_GIT_ENV, ...refs.indexEnv },
|
||||
},
|
||||
);
|
||||
for (const file of untrackedOut.split("\0").filter(Boolean)) {
|
||||
@@ -671,7 +673,7 @@ async function getTrackedNumstatByPath(
|
||||
}),
|
||||
{
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
envOverlay: { ...READ_ONLY_GIT_ENV, ...refs.indexEnv },
|
||||
maxOutputBytes: TRACKED_DIFF_NUMSTAT_MAX_BYTES,
|
||||
acceptExitCodes: [0],
|
||||
},
|
||||
@@ -736,7 +738,7 @@ async function getTrackedDiffTextForPath(input: {
|
||||
}),
|
||||
{
|
||||
cwd: input.cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
envOverlay: { ...READ_ONLY_GIT_ENV, ...input.refsForDiff.indexEnv },
|
||||
maxOutputBytes: PER_FILE_DIFF_MAX_BYTES,
|
||||
},
|
||||
);
|
||||
@@ -779,7 +781,11 @@ async function getTrackedDiffTexts(input: {
|
||||
...input.paths.map((path) => `:(literal)${path}`),
|
||||
],
|
||||
}),
|
||||
{ cwd: input.cwd, envOverlay: READ_ONLY_GIT_ENV, maxOutputBytes: 8 * PER_FILE_DIFF_MAX_BYTES },
|
||||
{
|
||||
cwd: input.cwd,
|
||||
envOverlay: { ...READ_ONLY_GIT_ENV, ...input.refsForDiff.indexEnv },
|
||||
maxOutputBytes: 8 * PER_FILE_DIFF_MAX_BYTES,
|
||||
},
|
||||
);
|
||||
const files = new Map<string, { path: string; text: string; truncated: boolean }>();
|
||||
if (!result.truncated) {
|
||||
@@ -905,7 +911,7 @@ export type CheckoutDiffResult =
|
||||
| { diff: ""; structured: []; diffTooLarge: true };
|
||||
|
||||
export interface CheckoutDiffCompare {
|
||||
mode: "uncommitted" | "base";
|
||||
mode: "uncommitted" | "base" | "all";
|
||||
baseRef?: string;
|
||||
ignoreWhitespace?: boolean;
|
||||
includeStructured?: boolean;
|
||||
@@ -3479,11 +3485,58 @@ async function resolveCheckoutDiffRefs(
|
||||
const bestBaseRef = await resolveBestComparisonBaseRef(cwd, baseRef);
|
||||
return {
|
||||
baseRef: (await tryResolveMergeBase(cwd, bestBaseRef)) ?? bestBaseRef,
|
||||
targetRef: "HEAD",
|
||||
includeUntracked: false,
|
||||
targetRef: compare.mode === "base" ? "HEAD" : undefined,
|
||||
includeUntracked: compare.mode === "all",
|
||||
};
|
||||
}
|
||||
|
||||
// Git's working-tree diff ignores files absent from the index, even when they
|
||||
// exist in the comparison tree. Restore those entries only in a private index.
|
||||
async function prepareAllChangesIndex(cwd: string, baseRef: string) {
|
||||
const untracked = await runGitCommand(["ls-files", "-z", "--others", "--exclude-standard"], {
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
const paths = untracked.stdout.split("\0").filter(Boolean);
|
||||
if (paths.length === 0) return null;
|
||||
let entries = "";
|
||||
for (let start = 0; start < paths.length; start += TRACKED_DIFF_BATCH_SIZE) {
|
||||
const result = await runGitCommand(
|
||||
[
|
||||
"ls-tree",
|
||||
"-r",
|
||||
"-z",
|
||||
baseRef,
|
||||
"--",
|
||||
...paths.slice(start, start + TRACKED_DIFF_BATCH_SIZE).map((path) => `:(literal)${path}`),
|
||||
],
|
||||
{ cwd, envOverlay: READ_ONLY_GIT_ENV },
|
||||
);
|
||||
entries += result.stdout;
|
||||
}
|
||||
if (!entries) return null;
|
||||
const directory = await mkdtemp(resolve(tmpdir(), "paseo-all-changes-"));
|
||||
const indexFile = resolve(directory, "index");
|
||||
try {
|
||||
const index = await runGitCommand(["rev-parse", "--git-path", "index"], {
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
const indexPath = resolveGitRevParsePath(cwd, index.stdout);
|
||||
if (!indexPath) throw new Error("Git did not return an index path");
|
||||
await copyFile(indexPath, indexFile);
|
||||
await runGitCommand(["update-index", "-z", "--index-info"], {
|
||||
cwd,
|
||||
input: entries,
|
||||
envOverlay: { ...READ_ONLY_GIT_ENV, GIT_INDEX_FILE: indexFile },
|
||||
});
|
||||
return { indexFile, cleanup: () => rm(directory, { recursive: true, force: true }) };
|
||||
} catch (error) {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCheckoutDiff(
|
||||
cwd: string,
|
||||
compare: CheckoutDiffCompare,
|
||||
@@ -3496,6 +3549,23 @@ export async function getCheckoutDiff(
|
||||
return { diff: "" };
|
||||
}
|
||||
|
||||
const index =
|
||||
compare.mode === "all" ? await prepareAllChangesIndex(cwd, refsForDiff.baseRef) : null;
|
||||
try {
|
||||
return await readCheckoutDiff(cwd, compare, {
|
||||
...refsForDiff,
|
||||
indexEnv: index ? { GIT_INDEX_FILE: index.indexFile } : undefined,
|
||||
});
|
||||
} finally {
|
||||
await index?.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function readCheckoutDiff(
|
||||
cwd: string,
|
||||
compare: CheckoutDiffCompare,
|
||||
refsForDiff: CheckoutDiffRefs,
|
||||
): Promise<CheckoutDiffResult> {
|
||||
const ignoreWhitespace = compare.ignoreWhitespace === true;
|
||||
let effectiveRefsForDiff = refsForDiff;
|
||||
let changes: CheckoutFileChange[];
|
||||
|
||||
Reference in New Issue
Block a user