mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-11 10:57:07 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f02537727a | ||
|
|
870c8dc9c5 | ||
|
|
d3c1cc54bb | ||
|
|
5805b776c2 | ||
|
|
4400445edb | ||
|
|
337e81a9c6 |
+60
@@ -12,6 +12,7 @@ interface QueryMock {
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
setPermissionMode: ReturnType<typeof vi.fn>;
|
||||
setModel: ReturnType<typeof vi.fn>;
|
||||
applyFlagSettings: ReturnType<typeof vi.fn>;
|
||||
supportedModels: ReturnType<typeof vi.fn>;
|
||||
supportedCommands: ReturnType<typeof vi.fn>;
|
||||
rewindFiles: ReturnType<typeof vi.fn>;
|
||||
@@ -148,6 +149,7 @@ function createScriptedQuery(params: {
|
||||
close: vi.fn(() => undefined),
|
||||
setPermissionMode: vi.fn(async () => undefined),
|
||||
setModel: vi.fn(async () => undefined),
|
||||
applyFlagSettings: vi.fn(async () => undefined),
|
||||
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
||||
supportedCommands: vi.fn(async () => []),
|
||||
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
||||
@@ -276,6 +278,64 @@ afterEach(() => {
|
||||
queryFactory.mockReset();
|
||||
});
|
||||
|
||||
async function createThinkingRestartSession() {
|
||||
const queries: ScriptedQuery[] = [];
|
||||
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
const scriptedQuery = createScriptedQuery({
|
||||
prompt,
|
||||
sessionId: "thinking-restart-session",
|
||||
handlePrompt: ({ query }) => query.emit(buildSuccessResult("thinking-restart-session")),
|
||||
});
|
||||
queries.push(scriptedQuery);
|
||||
return scriptedQuery;
|
||||
});
|
||||
const session = await new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
}).createSession({ provider: "claude", cwd: process.cwd() });
|
||||
return { session, queries };
|
||||
}
|
||||
|
||||
test("submits after a thinking change before the first turn", async () => {
|
||||
const { session, queries } = await createThinkingRestartSession();
|
||||
|
||||
try {
|
||||
await session.setThinkingOption!("medium");
|
||||
const events = await collectUntilTerminal(
|
||||
streamSession(session, "continue", { clientMessageId: "client-continue" }),
|
||||
);
|
||||
expect(events.filter((event) => event.type === "turn_failed")).toEqual([]);
|
||||
expect(events.at(-1)?.type).toBe("turn_completed");
|
||||
expect(queryFactory).toHaveBeenCalledTimes(1);
|
||||
expect(queries[0].prompts.map((prompt) => prompt.text)).toEqual(["continue"]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("submits after a thinking change once the previous query ended", async () => {
|
||||
const { session, queries } = await createThinkingRestartSession();
|
||||
|
||||
try {
|
||||
await collectUntilTerminal(streamSession(session, "first prompt"));
|
||||
queries[0].end();
|
||||
// Let the pump retire the old query before changing thinking.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
await session.setThinkingOption!("medium");
|
||||
const events = await collectUntilTerminal(
|
||||
streamSession(session, "continue", { clientMessageId: "client-continue" }),
|
||||
);
|
||||
expect(events.filter((event) => event.type === "turn_failed")).toEqual([]);
|
||||
expect(events.at(-1)?.type).toBe("turn_completed");
|
||||
expect(queryFactory).toHaveBeenCalledTimes(2);
|
||||
expect(queries[1].prompts.map((prompt) => prompt.text)).toEqual(["continue"]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("interrupt only calls query.interrupt and leaves the query open", async () => {
|
||||
const logger = createTestLogger();
|
||||
const queries: ScriptedQuery[] = [];
|
||||
|
||||
@@ -3275,6 +3275,9 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
// A fresh query already uses the current settings, even when there was no old query to retire.
|
||||
this.queryRestartNeeded = false;
|
||||
|
||||
// Preserve claudeSessionId across query recreation so buildOptions() passes
|
||||
// resume: sessionId and the new query continues the existing conversation.
|
||||
this.persistence = null;
|
||||
@@ -3877,11 +3880,12 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private startQueryPump(): void {
|
||||
if (this.closed || this.queryPumpPromise) {
|
||||
if (this.closed || this.queryPumpPromise || !this.query) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pump = this.runQueryPump().catch((error) => {
|
||||
// Drain the initialized query; a pending settings change belongs to the next turn.
|
||||
const pump = this.runQueryPump(this.query).catch((error) => {
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: this.agentId,
|
||||
@@ -3902,25 +3906,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
});
|
||||
}
|
||||
|
||||
private async runQueryPump(): Promise<void> {
|
||||
let activeQuery: Query;
|
||||
try {
|
||||
activeQuery = await this.ensureQuery();
|
||||
} catch (error) {
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: this.agentId,
|
||||
provider: "claude",
|
||||
sessionId: this.claudeSessionId,
|
||||
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
|
||||
err: error,
|
||||
},
|
||||
"provider.claude.query_pump.init_failed",
|
||||
);
|
||||
this.failActiveTurns(error instanceof Error ? error.message : "Claude stream failed");
|
||||
return;
|
||||
}
|
||||
|
||||
private async runQueryPump(activeQuery: Query): Promise<void> {
|
||||
let consecutiveInterruptAbortRecoveries = 0;
|
||||
const logRawMessage = (message: SDKMessage): void => {
|
||||
this.logger.trace(
|
||||
|
||||
@@ -115,4 +115,43 @@ describe("daemon E2E (real claude) - thinking effort memory", () => {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 420_000);
|
||||
|
||||
test("a thinking change before the first turn does not fail that turn", async () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
const cwd = tmpCwd();
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: createRealProviderClients(["claude"], logger),
|
||||
logger,
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
|
||||
const modelList = await client.listProviderModels("claude", { cwd });
|
||||
const model = modelList.models.find(modelHasLowThinkingOption);
|
||||
if (!model) {
|
||||
throw new Error("No Claude Sonnet model with low thinking effort returned");
|
||||
}
|
||||
|
||||
const agent = await client.createAgent({
|
||||
cwd,
|
||||
title: "claude-thinking-before-first-turn-real",
|
||||
...getRealProviderConfig("claude"),
|
||||
model: model.id,
|
||||
});
|
||||
await client.setAgentThinkingOption(agent.id, "low");
|
||||
|
||||
await client.sendMessage(agent.id, "Reply exactly: ACK_FIRST");
|
||||
const finish = await client.waitForFinish(agent.id, 180_000);
|
||||
expect(finish.final?.lastError).toBeUndefined();
|
||||
expect(finish.status).toBe("idle");
|
||||
expect(compactText(await getAssistantText(client, agent.id))).toContain("ack_first");
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close().catch(() => undefined);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 300_000);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, writeFileSync, rmSync, mkdirSync, symlinkSync, readdirSync } from "fs";
|
||||
import { describe, test, expect, beforeEach, afterEach, onTestFinished } from "vitest";
|
||||
import {
|
||||
mkdtempSync,
|
||||
writeFileSync,
|
||||
rmSync,
|
||||
mkdirSync,
|
||||
symlinkSync,
|
||||
readdirSync,
|
||||
readlinkSync,
|
||||
} from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import path from "path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
|
||||
|
||||
function tmpCwd(): string {
|
||||
@@ -387,6 +396,84 @@ describe("file explorer", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"duplicating a folder keeps relative symlinks inside the copy",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
try {
|
||||
mkdirSync(path.join(cwd, "template"));
|
||||
writeFileSync(path.join(cwd, "template", "data.txt"), "original payload\n");
|
||||
symlinkSync("data.txt", path.join(cwd, "template", "alias.txt"));
|
||||
|
||||
const duplicated = await ctx.client.duplicateFileEntry({ cwd, path: "template" });
|
||||
expect(duplicated).toMatchObject({
|
||||
duplicatedPath: "template copy",
|
||||
success: true,
|
||||
error: null,
|
||||
});
|
||||
expect(readlinkSync(path.join(cwd, "template copy", "alias.txt"))).toBe("data.txt");
|
||||
|
||||
const copiedTarget = await ctx.client.readFile(cwd, "template copy/data.txt");
|
||||
const targetWrite = await ctx.client.writeFile({
|
||||
cwd,
|
||||
path: "template copy/data.txt",
|
||||
content: "copy payload\n",
|
||||
expectedModifiedAt: copiedTarget.modifiedAt,
|
||||
expectedRevision: copiedTarget.revision,
|
||||
});
|
||||
expect(targetWrite.status).toBe("written");
|
||||
const copiedAlias = await ctx.client.readFile(cwd, "template copy/alias.txt");
|
||||
expect(new TextDecoder().decode(copiedAlias.bytes)).toBe("copy payload\n");
|
||||
|
||||
const aliasWrite = await ctx.client.writeFile({
|
||||
cwd,
|
||||
path: "template copy/alias.txt",
|
||||
content: "edited through copied alias\n",
|
||||
expectedModifiedAt: copiedAlias.modifiedAt,
|
||||
expectedRevision: copiedAlias.revision,
|
||||
});
|
||||
expect(aliasWrite.status).toBe("written");
|
||||
const original = await ctx.client.readFile(cwd, "template/data.txt");
|
||||
expect(new TextDecoder().decode(original.bytes)).toBe("original payload\n");
|
||||
const editedCopy = await ctx.client.readFile(cwd, "template copy/data.txt");
|
||||
expect(new TextDecoder().decode(editedCopy.bytes)).toBe("edited through copied alias\n");
|
||||
|
||||
const deleted = await ctx.client.deleteFileEntry({ cwd, path: "template" });
|
||||
expect(deleted).toMatchObject({ success: true, error: null });
|
||||
const survivingAlias = await ctx.client.readFile(cwd, "template copy/alias.txt");
|
||||
expect(new TextDecoder().decode(survivingAlias.bytes)).toBe(
|
||||
"edited through copied alias\n",
|
||||
);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"duplicating a file symlink preserves an explicit absolute target",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
const targetPath = path.join(cwd, "data.txt");
|
||||
try {
|
||||
writeFileSync(targetPath, "absolute target payload\n");
|
||||
symlinkSync(targetPath, path.join(cwd, "alias.txt"));
|
||||
|
||||
const duplicated = await ctx.client.duplicateFileEntry({ cwd, path: "alias.txt" });
|
||||
expect(duplicated).toMatchObject({
|
||||
duplicatedPath: "alias copy.txt",
|
||||
success: true,
|
||||
error: null,
|
||||
});
|
||||
expect(readlinkSync(path.join(cwd, "alias copy.txt"))).toBe(targetPath);
|
||||
const copy = await ctx.client.readFile(cwd, "alias copy.txt");
|
||||
expect(new TextDecoder().decode(copy.bytes)).toBe("absolute target payload\n");
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("lists directory contents", async () => {
|
||||
const cwd = tmpCwd();
|
||||
|
||||
@@ -489,3 +576,63 @@ describe("file explorer", () => {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 60000); // 1 minute timeout
|
||||
});
|
||||
|
||||
describe("commit file diffs", () => {
|
||||
test.each([".", "apps/web"])("opens committed files from %s workspaces", async (relativePath) => {
|
||||
const cwd = tmpCwd();
|
||||
onTestFinished(() =>
|
||||
rmSync(cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),
|
||||
);
|
||||
const workspace = path.join(cwd, relativePath);
|
||||
const filePath = "apps/web/notes.txt";
|
||||
function git(args: string[]): string {
|
||||
return execFileSync("git", args, { cwd, encoding: "utf8" });
|
||||
}
|
||||
git(["init", "-b", "main"]);
|
||||
git(["config", "user.name", "Test"]);
|
||||
git(["config", "user.email", "test@example.com"]);
|
||||
git(["config", "core.autocrlf", "false"]);
|
||||
mkdirSync(path.join(cwd, "apps", "web"), { recursive: true });
|
||||
writeFileSync(path.join(cwd, filePath), "before\n");
|
||||
writeFileSync(path.join(cwd, "apps/web/untouched.txt"), "unchanged\n");
|
||||
git(["add", "."]);
|
||||
git(["-c", "commit.gpgsign=false", "commit", "-m", "initial"]);
|
||||
git(["checkout", "-b", "feature"]);
|
||||
writeFileSync(path.join(cwd, filePath), "after\n");
|
||||
git(["add", "."]);
|
||||
git(["-c", "commit.gpgsign=false", "commit", "-m", "edit"]);
|
||||
const sha = git(["rev-parse", "HEAD"]).trim();
|
||||
|
||||
const history = await ctx.client.listCheckoutCommits(workspace);
|
||||
const commit = history.commits.find((entry) => entry.sha === sha);
|
||||
expect(commit?.files).toEqual([
|
||||
{ path: filePath, additions: 1, deletions: 1, status: "modified" },
|
||||
]);
|
||||
const result = await ctx.client.getCommitFileDiff(workspace, sha, filePath);
|
||||
expect(result).toEqual({
|
||||
file: {
|
||||
path: filePath,
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
hunks: [
|
||||
{
|
||||
oldStart: 1,
|
||||
oldCount: 1,
|
||||
newStart: 1,
|
||||
newCount: 1,
|
||||
lines: [
|
||||
{ type: "header", content: "@@ -1 +1 @@" },
|
||||
{ type: "remove", content: "before" },
|
||||
{ type: "add", content: "after" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(await ctx.client.getCommitFileDiff(workspace, sha, "apps/web/untouched.txt")).toEqual({
|
||||
file: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { execFile as execFileCallback } from "node:child_process";
|
||||
import { mkdtempSync, realpathSync } from "node:fs";
|
||||
import { readFile, rm } from "node:fs/promises";
|
||||
import { readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
@@ -63,10 +63,12 @@ test("an empty project becomes Git without changing its identity or creating a w
|
||||
cleanupClients.add(client);
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
const projectEvents = client.observeEvents(["project.update"]);
|
||||
await projectEvents.ready;
|
||||
|
||||
const added = await client.addProject(projectRoot);
|
||||
|
||||
expect(added).toEqual({
|
||||
expect(added).toMatchObject({
|
||||
requestId: expect.any(String),
|
||||
project: {
|
||||
projectId: expect.stringMatching(/^prj_[0-9a-f]{16}$/),
|
||||
@@ -77,7 +79,11 @@ test("an empty project becomes Git without changing its identity or creating a w
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
const project = added.project!;
|
||||
const {
|
||||
projectIconRevision: _icon,
|
||||
projectCustomIconRevision: _customIcon,
|
||||
...project
|
||||
} = added.project!;
|
||||
const beforeGitInit = await client.fetchWorkspaces({ filter: { projectId: project.projectId } });
|
||||
expect(beforeGitInit).toMatchObject({ entries: [], emptyProjects: [project] });
|
||||
|
||||
@@ -96,15 +102,15 @@ test("an empty project becomes Git without changing its identity or creating a w
|
||||
label: "project.update after git init",
|
||||
});
|
||||
|
||||
expect(update).toEqual({
|
||||
expect(update).toMatchObject({
|
||||
kind: "upsert",
|
||||
project: { ...project, projectKind: "git" },
|
||||
project: { ...project, projectKind: "git", projectKey: expect.any(String) },
|
||||
});
|
||||
|
||||
const afterGitInit = await client.fetchWorkspaces({ filter: { projectId: project.projectId } });
|
||||
expect(afterGitInit).toMatchObject({
|
||||
entries: [],
|
||||
emptyProjects: [{ ...project, projectKind: "git" }],
|
||||
emptyProjects: [{ ...project, projectKind: "git", projectKey: expect.any(String) }],
|
||||
});
|
||||
|
||||
const persistedProjects = JSON.parse(
|
||||
@@ -119,5 +125,72 @@ test("an empty project becomes Git without changing its identity or creating a w
|
||||
createdAt: expect.any(String),
|
||||
updatedAt: expect.any(String),
|
||||
archivedAt: null,
|
||||
projectKey: expect.any(String),
|
||||
customIconRevision: null,
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
test("a failed worktree attempt before git init does not prevent creation afterward", async () => {
|
||||
const projectRoot = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-init-worktree-")));
|
||||
const paseoHomeRoot = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-init-home-")));
|
||||
cleanupPaths.add(projectRoot);
|
||||
cleanupPaths.add(paseoHomeRoot);
|
||||
await writeFile(path.join(projectRoot, "README.md"), "hi\n");
|
||||
|
||||
const daemon = await createTestPaseoDaemon({ paseoHomeRoot, cleanup: false });
|
||||
cleanupDaemons.add(daemon);
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
cleanupClients.add(client);
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
const projectEvents = client.observeEvents(["project.update"]);
|
||||
await projectEvents.ready;
|
||||
const added = await client.addProject(projectRoot);
|
||||
expect(added.project?.projectKind).toBe("non_git");
|
||||
const source = {
|
||||
kind: "worktree" as const,
|
||||
cwd: projectRoot,
|
||||
projectId: added.project!.projectId,
|
||||
action: "branch-off" as const,
|
||||
refName: "main",
|
||||
branchName: "after-init",
|
||||
worktreeSlug: "after-init",
|
||||
};
|
||||
const before = await client.createWorkspace({ source });
|
||||
expect(before.error).toBe("Create worktree requires a git repository");
|
||||
|
||||
const gitProjectUpdate = waitForProjectUpdate(
|
||||
client,
|
||||
(payload) =>
|
||||
payload.kind === "upsert" &&
|
||||
payload.project.projectId === added.project!.projectId &&
|
||||
payload.project.projectKind === "git",
|
||||
);
|
||||
await execFile("git", ["init", "-b", "main"], { cwd: projectRoot });
|
||||
await execFile("git", ["add", "README.md"], { cwd: projectRoot });
|
||||
await execFile(
|
||||
"git",
|
||||
[
|
||||
"-c",
|
||||
"user.name=Paseo Test",
|
||||
"-c",
|
||||
"user.email=test@getpaseo.dev",
|
||||
"-c",
|
||||
"commit.gpgSign=false",
|
||||
"commit",
|
||||
"-m",
|
||||
"initial",
|
||||
],
|
||||
{ cwd: projectRoot },
|
||||
);
|
||||
await withTimeout({ promise: gitProjectUpdate, timeoutMs: 10_000, label: "project becomes Git" });
|
||||
const projects = await client.fetchWorkspaces({
|
||||
filter: { projectId: added.project!.projectId },
|
||||
});
|
||||
expect(projects.emptyProjects[0]?.projectKind).toBe("git");
|
||||
|
||||
const created = await client.createWorkspace({ source });
|
||||
expect(created.error).toBeNull();
|
||||
expect(created.workspace?.workspaceKind).toBe("worktree");
|
||||
expect(created.workspace?.projectId).toBe(added.project!.projectId);
|
||||
}, 30_000);
|
||||
|
||||
@@ -669,6 +669,7 @@ export async function duplicateExplorerEntry({
|
||||
errorOnExist: true,
|
||||
force: false,
|
||||
preserveTimestamps: true,
|
||||
verbatimSymlinks: true,
|
||||
});
|
||||
return { status: "ok", path: normalizeRelativePath({ root, targetPath }) };
|
||||
} catch (error) {
|
||||
|
||||
@@ -236,7 +236,8 @@ test("observes a thousand concurrent writes and remains healthy after delete and
|
||||
const deletions = expectFileChanges(removedPaths, "delete");
|
||||
const movedFiles = expectFileChanges(movedPaths);
|
||||
const recovery = expectFileChanges([sentinel]);
|
||||
const subscription = await subscribeToFileChanges(root, (error, events) => {
|
||||
const observer = createObserver();
|
||||
const subscription = await observer.subscribe(root, (error, events) => {
|
||||
expect(error).toBeNull();
|
||||
for (const event of events) {
|
||||
population.record(event);
|
||||
@@ -254,9 +255,18 @@ test("observes a thousand concurrent writes and remains healthy after delete and
|
||||
await Promise.all(paths.map((path, index) => writeFile(path, `${index}`)));
|
||||
await writes.complete;
|
||||
|
||||
// A native watcher may recover coalesced paths through its safety audit.
|
||||
// Completion comes from the delivered deletions, not an idle diagnostic or
|
||||
// a deadline shorter than that recovery cycle.
|
||||
// The first notice for each write can arrive before its remaining
|
||||
// classifications. Drain those deliveries before starting a separate deletion
|
||||
// phase, so a late create cannot coalesce with the expected delete as update.
|
||||
await expect
|
||||
.poll(() => observer.getDiagnostics(), { timeout: 10_000 })
|
||||
.toMatchObject({
|
||||
pendingEventCount: 0,
|
||||
pendingClassificationCount: 0,
|
||||
reconciliationInFlightCount: 0,
|
||||
});
|
||||
|
||||
// Completion still requires the delivered deletion notices.
|
||||
await Promise.all(removedPaths.map((path) => rm(path)));
|
||||
await deletions.complete;
|
||||
|
||||
|
||||
@@ -63,6 +63,27 @@ afterEach(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("a fresh checkout read makes a newly initialized repository available to cached readers", async () => {
|
||||
const root = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-checkout-init-")));
|
||||
const repo = path.join(root, "repo");
|
||||
mkdirSync(repo);
|
||||
const service = new WorkspaceGitServiceImpl({
|
||||
logger: pino({ enabled: false }),
|
||||
paseoHome: path.join(root, "home"),
|
||||
});
|
||||
cleanup.push(async () => {
|
||||
await service.dispose();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
expect((await service.getSnapshot(repo, { includeForge: false })).git.isGit).toBe(false);
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: repo, stdio: "pipe" });
|
||||
|
||||
expect((await service.getCheckout(repo)).isGit).toBe(true);
|
||||
const repoRoot = await service.resolveRepoRoot(repo);
|
||||
expect(realpathSync.native(repoRoot)).toBe(realpathSync.native(repo));
|
||||
}, 30_000);
|
||||
|
||||
test("recursive observation updates tracked state and prunes ignored storms", async () => {
|
||||
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-git-observation-")));
|
||||
const repoDir = path.join(tempDir, "repo");
|
||||
|
||||
@@ -765,6 +765,16 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
worktreesRoot: this.worktreesRoot,
|
||||
logger: this.logger,
|
||||
});
|
||||
const cached = this.peekSnapshot(normalizedCwd);
|
||||
if (cached && cached.git.isGit !== status.isGit) {
|
||||
// A fresh checkout read discovered a repository transition. Keep the
|
||||
// shared snapshot current before exposing the new project capability.
|
||||
await this.getSnapshot(normalizedCwd, {
|
||||
force: true,
|
||||
includeForge: false,
|
||||
reason: "checkout-classification-changed",
|
||||
});
|
||||
}
|
||||
if (!status.isGit) {
|
||||
return checkoutLiteFromGitSnapshot(normalizedCwd, {
|
||||
isGit: false,
|
||||
|
||||
@@ -2665,10 +2665,11 @@ export async function getCommitFileDiff({
|
||||
sha: string;
|
||||
path: string;
|
||||
}): Promise<ParsedDiffFile | null> {
|
||||
const worktreeRoot = await requireGitWorktreeRoot(cwd);
|
||||
const { stdout } = await runGitCommand(
|
||||
["show", sha, "--format=", "--diff-merges=first-parent", ...GIT_PATCH_FORMAT_ARGS, "--", path],
|
||||
{
|
||||
cwd,
|
||||
cwd: worktreeRoot,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
},
|
||||
);
|
||||
@@ -2677,9 +2678,9 @@ export async function getCommitFileDiff({
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedFiles = await parseAndHighlightDiff(stdout, cwd, {
|
||||
getOldFileContent: (file) => readGitFileContentAtRef(cwd, `${sha}^`, file.path),
|
||||
getNewFileContent: (file) => readGitFileContentAtRef(cwd, sha, file.path),
|
||||
const parsedFiles = await parseAndHighlightDiff(stdout, worktreeRoot, {
|
||||
getOldFileContent: (file) => readGitFileContentAtRef(worktreeRoot, `${sha}^`, file.path),
|
||||
getNewFileContent: (file) => readGitFileContentAtRef(worktreeRoot, sha, file.path),
|
||||
});
|
||||
|
||||
// `--` scopes the diff to a single pathspec, so there is at most one real
|
||||
|
||||
Reference in New Issue
Block a user