mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Allow worktree creation after Git initialization (#6785)
* fix(server): allow worktree creation after Git initialization Fresh project checkout reads could publish Git capability while worktree creation retained a cached non-Git snapshot. Refresh an existing snapshot when the fresh read observes a classification change, before returning the new capability. * test(server): cover Git initialization in the standard integration suite * test(server): separate stress-test writes from deletion delivery Wait for active classifications and queued deliveries between the distinct write and deletion phases. Otherwise a late create and delete coalesce as update, leaving the strict deletion assertion waiting indefinitely. Preserve every event assertion without waiting for scheduled native safety audits. * test(server): compare canonical repository paths on Windows --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user