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:
Mohamed Boudra
2026-04-01 04:38:50 +00:00
co-authored by Claude Opus 4.6
parent ee50d3b8d0
commit 07b077f1a2
5 changed files with 302 additions and 45 deletions
-1
View File
@@ -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();
}
@@ -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,11 +299,60 @@ 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) {
if (process.platform === "linux" && watchPath === repoWatchPath) {
continue;
}
const shouldTryRecursive = watchPath === repoWatchPath && allowRecursiveRepoWatch;
const watcherIsRecursive = this.addWatcher(target, watchPath, shouldTryRecursive);
if (watchPath === repoWatchPath && watcherIsRecursive) {
hasRecursiveRepoCoverage = true;
}
}
const missingRepoCoverage = !hasRecursiveRepoCoverage;
if (target.watchers.length === 0 || missingRepoCoverage) {
target.fallbackRefreshInterval = setInterval(() => {
this.scheduleTargetRefresh(target);
}, CHECKOUT_DIFF_FALLBACK_REFRESH_MS);
this.logger.warn(
{
cwd,
compare,
intervalMs: CHECKOUT_DIFF_FALLBACK_REFRESH_MS,
reason:
target.watchers.length === 0 ? "no_watchers" : "missing_recursive_repo_root_coverage",
},
"Checkout diff watchers unavailable; using timed refresh fallback",
);
}
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 }, () => {
this.scheduleTargetRefresh(target);
onChange();
});
let watcher: FSWatcher | null = null;
@@ -326,36 +387,94 @@ export class CheckoutDiffManager {
}
if (!watcher) {
continue;
return false;
}
watcher.on("error", (error) => {
this.logger.warn({ err: error, watchPath, cwd, compare }, "Checkout diff watcher error");
});
target.watchers.push(watcher);
if (watchPath === repoWatchPath && watcherIsRecursive) {
hasRecursiveRepoCoverage = true;
}
target.watchedPaths.add(watchPath);
return watcherIsRecursive;
}
const missingRepoCoverage = !hasRecursiveRepoCoverage;
if (target.watchers.length === 0 || missingRepoCoverage) {
target.fallbackRefreshInterval = setInterval(() => {
this.scheduleTargetRefresh(target);
}, CHECKOUT_DIFF_FALLBACK_REFRESH_MS);
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(
{
cwd,
compare,
intervalMs: CHECKOUT_DIFF_FALLBACK_REFRESH_MS,
reason:
target.watchers.length === 0 ? "no_watchers" : "missing_recursive_repo_root_coverage",
err: error,
cwd: target.cwd,
compare: target.compare,
rootPath,
},
"Checkout diff watchers unavailable; using timed refresh fallback",
"Failed to refresh Linux checkout diff tree watchers",
);
}
} while (target.linuxTreeRefreshQueued);
})();
this.targets.set(targetKey, target);
return target;
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;
}
}