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:
paseo-bot[bot]
2026-10-13 13:55:25 +02:00
committed by GitHub
parent 337e81a9c6
commit 4400445edb
4 changed files with 124 additions and 10 deletions
@@ -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,