mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
fix: Linux checkout diff watchers and rewind command ordering
Use non-recursive directory watchers on Linux since recursive fs.watch is not supported. Dynamically discover and watch subdirectories, refreshing the watcher tree on changes. Also pin rewind command first in the slash command list and remove stale .gitignore entry. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -82,4 +82,3 @@ packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript
|
||||
|
||||
/artifacts
|
||||
packages/desktop/.cache/
|
||||
packages/desktop/src-tauri/resources/managed-runtime/
|
||||
|
||||
@@ -1589,7 +1589,11 @@ class ClaudeAgentSession implements AgentSession {
|
||||
if (!commandMap.has(REWIND_COMMAND_NAME)) {
|
||||
commandMap.set(REWIND_COMMAND_NAME, REWIND_COMMAND);
|
||||
}
|
||||
return Array.from(commandMap.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||
const rewindCommand = commandMap.get(REWIND_COMMAND_NAME);
|
||||
const remainingCommands = Array.from(commandMap.values())
|
||||
.filter((command) => command.name !== REWIND_COMMAND_NAME)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return rewindCommand ? [rewindCommand, ...remainingCommands] : remainingCommands;
|
||||
}
|
||||
|
||||
private resolveSlashCommandInvocation(prompt: AgentPromptInput): SlashCommandInvocation | null {
|
||||
|
||||
@@ -234,6 +234,7 @@ describe("ClaudeAgentSession history replay regression", () => {
|
||||
try {
|
||||
const commands = await session.listCommands?.();
|
||||
expect(commands?.some((command) => command.name === "rewind")).toBe(true);
|
||||
expect(commands?.[0]?.name).toBe("rewind");
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
|
||||
134
packages/server/src/server/checkout-diff-manager.test.ts
Normal file
134
packages/server/src/server/checkout-diff-manager.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const { execMock, getCheckoutDiffMock, resolveCheckoutGitDirMock, readdirMock, watchCalls } =
|
||||
vi.hoisted(() => {
|
||||
const hoistedWatchCalls: Array<{ path: string; close: ReturnType<typeof vi.fn> }> = [];
|
||||
return {
|
||||
execMock: vi.fn((_command: string, _options: unknown, callback: (error: null, result: { stdout: string; stderr: string }) => void) => {
|
||||
callback(null, { stdout: "/tmp/repo\n", stderr: "" });
|
||||
}),
|
||||
getCheckoutDiffMock: vi.fn(async () => ({ diff: "", structured: [] })),
|
||||
resolveCheckoutGitDirMock: vi.fn(async () => "/tmp/repo/.git"),
|
||||
readdirMock: vi.fn(async (directory: string) => {
|
||||
if (directory === "/tmp/repo") {
|
||||
return [
|
||||
{ name: "packages", isDirectory: () => true },
|
||||
{ name: ".git", isDirectory: () => true },
|
||||
{ name: "README.md", isDirectory: () => false },
|
||||
];
|
||||
}
|
||||
if (directory === path.join("/tmp/repo", "packages")) {
|
||||
return [
|
||||
{ name: "server", isDirectory: () => true },
|
||||
{ name: "app", isDirectory: () => true },
|
||||
];
|
||||
}
|
||||
if (directory === path.join("/tmp/repo", "packages", "server")) {
|
||||
return [{ name: "src", isDirectory: () => true }];
|
||||
}
|
||||
if (directory === path.join("/tmp/repo", "packages", "server", "src")) {
|
||||
return [{ name: "server", isDirectory: () => true }];
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
watchCalls: hoistedWatchCalls,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("child_process", () => ({
|
||||
exec: execMock,
|
||||
}));
|
||||
|
||||
vi.mock("node:fs/promises", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");
|
||||
return {
|
||||
...actual,
|
||||
readdir: readdirMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("node:fs", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
|
||||
return {
|
||||
...actual,
|
||||
watch: vi.fn((watchPath: string) => {
|
||||
const close = vi.fn();
|
||||
watchCalls.push({ path: watchPath, close });
|
||||
return {
|
||||
close,
|
||||
on: vi.fn().mockReturnThis(),
|
||||
} as any;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../utils/checkout-git.js", () => ({
|
||||
getCheckoutDiff: getCheckoutDiffMock,
|
||||
}));
|
||||
|
||||
vi.mock("./checkout-git-utils.js", () => ({
|
||||
READ_ONLY_GIT_ENV: {},
|
||||
resolveCheckoutGitDir: resolveCheckoutGitDirMock,
|
||||
toCheckoutError: vi.fn((error: unknown) => ({
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
})),
|
||||
}));
|
||||
|
||||
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
||||
|
||||
describe("CheckoutDiffManager Linux watchers", () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
beforeEach(() => {
|
||||
watchCalls.length = 0;
|
||||
execMock.mockClear();
|
||||
getCheckoutDiffMock.mockClear();
|
||||
resolveCheckoutGitDirMock.mockClear();
|
||||
readdirMock.mockClear();
|
||||
Object.defineProperty(process, "platform", {
|
||||
configurable: true,
|
||||
value: "linux",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, "platform", {
|
||||
configurable: true,
|
||||
value: originalPlatform,
|
||||
});
|
||||
});
|
||||
|
||||
test("watches nested repository directories on Linux", async () => {
|
||||
const logger = {
|
||||
child: () => logger,
|
||||
warn: vi.fn(),
|
||||
};
|
||||
const manager = new CheckoutDiffManager({
|
||||
logger: logger as any,
|
||||
paseoHome: "/tmp/paseo-test",
|
||||
});
|
||||
|
||||
const subscription = await manager.subscribe(
|
||||
{
|
||||
cwd: path.join("/tmp/repo", "packages", "server"),
|
||||
compare: { mode: "uncommitted" },
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
|
||||
expect(subscription.initial.error).toBeNull();
|
||||
expect(watchCalls.map((entry) => entry.path).sort()).toEqual([
|
||||
"/tmp/repo",
|
||||
"/tmp/repo/.git",
|
||||
"/tmp/repo/packages",
|
||||
"/tmp/repo/packages/app",
|
||||
"/tmp/repo/packages/server",
|
||||
"/tmp/repo/packages/server/src",
|
||||
"/tmp/repo/packages/server/src/server",
|
||||
]);
|
||||
|
||||
subscription.unsubscribe();
|
||||
manager.dispose();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { watch, type FSWatcher } from "node:fs";
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import type pino from "pino";
|
||||
@@ -39,6 +41,10 @@ type CheckoutDiffWatchTarget = {
|
||||
refreshQueued: boolean;
|
||||
latestPayload: CheckoutDiffSnapshotPayload | null;
|
||||
latestFingerprint: string | null;
|
||||
watchedPaths: Set<string>;
|
||||
repoWatchPath: string | null;
|
||||
linuxTreeRefreshPromise: Promise<void> | null;
|
||||
linuxTreeRefreshQueued: boolean;
|
||||
};
|
||||
|
||||
export class CheckoutDiffManager {
|
||||
@@ -145,6 +151,7 @@ export class CheckoutDiffManager {
|
||||
watcher.close();
|
||||
}
|
||||
target.watchers = [];
|
||||
target.watchedPaths.clear();
|
||||
target.listeners.clear();
|
||||
}
|
||||
|
||||
@@ -276,9 +283,14 @@ export class CheckoutDiffManager {
|
||||
refreshQueued: false,
|
||||
latestPayload: null,
|
||||
latestFingerprint: null,
|
||||
watchedPaths: new Set<string>(),
|
||||
repoWatchPath: null,
|
||||
linuxTreeRefreshPromise: null,
|
||||
linuxTreeRefreshQueued: false,
|
||||
};
|
||||
|
||||
const repoWatchPath = watchRoot ?? cwd;
|
||||
target.repoWatchPath = repoWatchPath;
|
||||
const watchPaths = new Set<string>([repoWatchPath]);
|
||||
const gitDir = await resolveCheckoutGitDir(cwd);
|
||||
if (gitDir) {
|
||||
@@ -287,52 +299,15 @@ export class CheckoutDiffManager {
|
||||
|
||||
let hasRecursiveRepoCoverage = false;
|
||||
const allowRecursiveRepoWatch = process.platform !== "linux";
|
||||
if (process.platform === "linux") {
|
||||
hasRecursiveRepoCoverage = await this.ensureLinuxRepoTreeWatchers(target, repoWatchPath);
|
||||
}
|
||||
for (const watchPath of watchPaths) {
|
||||
const shouldTryRecursive = watchPath === repoWatchPath && allowRecursiveRepoWatch;
|
||||
const createWatcher = (recursive: boolean): FSWatcher =>
|
||||
watch(watchPath, { recursive }, () => {
|
||||
this.scheduleTargetRefresh(target);
|
||||
});
|
||||
|
||||
let watcher: FSWatcher | null = null;
|
||||
let watcherIsRecursive = false;
|
||||
try {
|
||||
if (shouldTryRecursive) {
|
||||
watcher = createWatcher(true);
|
||||
watcherIsRecursive = true;
|
||||
} else {
|
||||
watcher = createWatcher(false);
|
||||
}
|
||||
} catch (error) {
|
||||
if (shouldTryRecursive) {
|
||||
try {
|
||||
watcher = createWatcher(false);
|
||||
this.logger.warn(
|
||||
{ err: error, watchPath, cwd, compare },
|
||||
"Checkout diff recursive watch unavailable; using non-recursive fallback",
|
||||
);
|
||||
} catch (fallbackError) {
|
||||
this.logger.warn(
|
||||
{ err: fallbackError, watchPath, cwd, compare },
|
||||
"Failed to start checkout diff watcher",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.logger.warn(
|
||||
{ err: error, watchPath, cwd, compare },
|
||||
"Failed to start checkout diff watcher",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!watcher) {
|
||||
if (process.platform === "linux" && watchPath === repoWatchPath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
watcher.on("error", (error) => {
|
||||
this.logger.warn({ err: error, watchPath, cwd, compare }, "Checkout diff watcher error");
|
||||
});
|
||||
target.watchers.push(watcher);
|
||||
const shouldTryRecursive = watchPath === repoWatchPath && allowRecursiveRepoWatch;
|
||||
const watcherIsRecursive = this.addWatcher(target, watchPath, shouldTryRecursive);
|
||||
if (watchPath === repoWatchPath && watcherIsRecursive) {
|
||||
hasRecursiveRepoCoverage = true;
|
||||
}
|
||||
@@ -358,4 +333,148 @@ export class CheckoutDiffManager {
|
||||
this.targets.set(targetKey, target);
|
||||
return target;
|
||||
}
|
||||
|
||||
private addWatcher(
|
||||
target: CheckoutDiffWatchTarget,
|
||||
watchPath: string,
|
||||
shouldTryRecursive: boolean,
|
||||
): boolean {
|
||||
if (target.watchedPaths.has(watchPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { cwd, compare } = target;
|
||||
const onChange = () => {
|
||||
if (process.platform === "linux" && target.repoWatchPath) {
|
||||
void this.refreshLinuxRepoTreeWatchers(target);
|
||||
}
|
||||
this.scheduleTargetRefresh(target);
|
||||
};
|
||||
const createWatcher = (recursive: boolean): FSWatcher =>
|
||||
watch(watchPath, { recursive }, () => {
|
||||
onChange();
|
||||
});
|
||||
|
||||
let watcher: FSWatcher | null = null;
|
||||
let watcherIsRecursive = false;
|
||||
try {
|
||||
if (shouldTryRecursive) {
|
||||
watcher = createWatcher(true);
|
||||
watcherIsRecursive = true;
|
||||
} else {
|
||||
watcher = createWatcher(false);
|
||||
}
|
||||
} catch (error) {
|
||||
if (shouldTryRecursive) {
|
||||
try {
|
||||
watcher = createWatcher(false);
|
||||
this.logger.warn(
|
||||
{ err: error, watchPath, cwd, compare },
|
||||
"Checkout diff recursive watch unavailable; using non-recursive fallback",
|
||||
);
|
||||
} catch (fallbackError) {
|
||||
this.logger.warn(
|
||||
{ err: fallbackError, watchPath, cwd, compare },
|
||||
"Failed to start checkout diff watcher",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.logger.warn(
|
||||
{ err: error, watchPath, cwd, compare },
|
||||
"Failed to start checkout diff watcher",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!watcher) {
|
||||
return false;
|
||||
}
|
||||
|
||||
watcher.on("error", (error) => {
|
||||
this.logger.warn({ err: error, watchPath, cwd, compare }, "Checkout diff watcher error");
|
||||
});
|
||||
target.watchers.push(watcher);
|
||||
target.watchedPaths.add(watchPath);
|
||||
return watcherIsRecursive;
|
||||
}
|
||||
|
||||
private async ensureLinuxRepoTreeWatchers(
|
||||
target: CheckoutDiffWatchTarget,
|
||||
rootPath: string,
|
||||
): Promise<boolean> {
|
||||
const directories = await this.listLinuxWatchDirectories(rootPath);
|
||||
let complete = true;
|
||||
for (const directory of directories) {
|
||||
const watcherWasRecursive = this.addWatcher(target, directory, false);
|
||||
if (!watcherWasRecursive && !target.watchedPaths.has(directory)) {
|
||||
complete = false;
|
||||
}
|
||||
}
|
||||
return complete && target.watchedPaths.has(rootPath);
|
||||
}
|
||||
|
||||
private async refreshLinuxRepoTreeWatchers(target: CheckoutDiffWatchTarget): Promise<void> {
|
||||
if (process.platform !== "linux" || !target.repoWatchPath) {
|
||||
return;
|
||||
}
|
||||
const rootPath = target.repoWatchPath;
|
||||
if (target.linuxTreeRefreshPromise) {
|
||||
target.linuxTreeRefreshQueued = true;
|
||||
return;
|
||||
}
|
||||
|
||||
target.linuxTreeRefreshPromise = (async () => {
|
||||
do {
|
||||
target.linuxTreeRefreshQueued = false;
|
||||
try {
|
||||
await this.ensureLinuxRepoTreeWatchers(target, rootPath);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{
|
||||
err: error,
|
||||
cwd: target.cwd,
|
||||
compare: target.compare,
|
||||
rootPath,
|
||||
},
|
||||
"Failed to refresh Linux checkout diff tree watchers",
|
||||
);
|
||||
}
|
||||
} while (target.linuxTreeRefreshQueued);
|
||||
})();
|
||||
|
||||
try {
|
||||
await target.linuxTreeRefreshPromise;
|
||||
} finally {
|
||||
target.linuxTreeRefreshPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async listLinuxWatchDirectories(rootPath: string): Promise<string[]> {
|
||||
const directories: string[] = [];
|
||||
const pending = [rootPath];
|
||||
|
||||
while (pending.length > 0) {
|
||||
const directory = pending.pop();
|
||||
if (!directory) {
|
||||
continue;
|
||||
}
|
||||
directories.push(directory);
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(directory, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name === ".git") {
|
||||
continue;
|
||||
}
|
||||
pending.push(join(directory, entry.name));
|
||||
}
|
||||
}
|
||||
|
||||
return directories;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user