Files
paseo/packages/server/src/utils/worktree.test.ts
T
Mohamed BoudraandMatt Cowger 9fb7808191 feat(worktrees): let plugins choose creation options and placement (#8117)
* feat(worktrees): let plugins choose creation options and placement

Await the worktree creation request hook before Git source planning so plugins can change placement, branch and base without undoing a fetch. Manage arbitrary destinations through marker and registry ownership, and restore them at their recorded paths.

Co-authored-by: Matt Cowger <matt@cowger.us>

* fix(worktrees): compare repository paths across Windows aliases

Deletion must recognize the same repository through Git native and long path spellings. Reuse ownership path equivalence instead of comparing canonical strings, and let the recovery fixture accept Git platform line endings.

Co-authored-by: Matt Cowger <matt@cowger.us>

* fix(worktrees): respect recorded ownership and creation intent

Keep branch checkout distinct from branch-off, validate plugin placement once at the daemon boundary, and share default path collision resolution. A restored external worktree's marker must never override its recorded non-ownership.

Co-authored-by: Matt Cowger <matt@cowger.us>

* test(worktrees): record ownership in session archive fixture

The fixture creates a Paseo-owned worktree, so its durable record must say so. Archive now treats a recorded false value as authoritative instead of replacing it with filesystem discovery.

Co-authored-by: Matt Cowger <matt@cowger.us>

* test(plugins): retry temp cleanup after worktree archive on Windows

Windows releases directory handles after the daemon closes, so rmdir on the
source repository can hit EBUSY. Retry like the other git temp-dir tests.

* test(plugins): compare plugin-chosen worktree paths in canonical spelling

Windows temp directories come back as 8.3 aliases while the daemon records
the resolved spelling, so normalize the expected root the same way.

---------

Co-authored-by: Matt Cowger <matt@cowger.us>
2026-10-28 18:55:50 +07:00

550 lines
19 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
createWorktree as createWorktreePrimitive,
deriveWorktreeProjectHash,
deletePaseoWorktree,
isPaseoOwnedWorktreeCwd,
mapWorkspaceCwdToWorktree,
runWorktreeTeardownCommands,
slugify,
type CreateWorktreeOptions,
type WorktreeConfig,
} from "./worktree";
import { execFileSync } from "child_process";
import { rm } from "node:fs/promises";
import {
mkdtempSync,
mkdirSync,
rmSync,
existsSync,
readFileSync,
realpathSync,
symlinkSync,
writeFileSync,
} from "fs";
import { join } from "path";
import { createServer, type AddressInfo, type Socket } from "net";
import { tmpdir } from "os";
import { createRealpathAwarePathMatcher } from "./path";
interface LegacyCreateWorktreeTestOptions {
branchName: string;
cwd: string;
baseBranch: string;
worktreeSlug: string;
paseoHome?: string;
}
function createLegacyWorktreeForTest(
options: CreateWorktreeOptions | LegacyCreateWorktreeTestOptions,
): Promise<WorktreeConfig> {
if ("source" in options) {
return createWorktreePrimitive(options);
}
return createWorktreePrimitive({
cwd: options.cwd,
worktreeSlug: options.worktreeSlug,
source: {
kind: "branch-off",
baseBranch: options.baseBranch,
branchName: options.branchName,
},
paseoHome: options.paseoHome,
});
}
// A remote that accepts connections and never answers, like a VPN-only host while off the VPN.
async function startSilentRemote(): Promise<{ url: string; close: () => Promise<void> }> {
const connections = new Set<Socket>();
const server = createServer((socket) => connections.add(socket));
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const { port } = server.address() as AddressInfo;
return {
url: `http://127.0.0.1:${port}/repo.git`,
close: () => {
for (const socket of connections) socket.destroy();
return new Promise<void>((resolve) => server.close(() => resolve()));
},
};
}
describe("paseo worktree manager", () => {
let tempDir: string;
let repoDir: string;
let paseoHome: string;
beforeEach(() => {
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "worktree-manager-test-")));
repoDir = join(tempDir, "test-repo");
paseoHome = join(tempDir, "paseo-home");
mkdirSync(repoDir, { recursive: true });
execFileSync("git", ["init", "-b", "main"], { cwd: repoDir });
execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: repoDir });
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir });
writeFileSync(join(repoDir, "file.txt"), "hello\n");
execFileSync("git", ["add", "."], { cwd: repoDir });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], {
cwd: repoDir,
});
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
});
it.each([
{ existing: [], preferred: "main/patch-1", expected: "main-1/patch-1" },
{ existing: ["main-1"], preferred: "main/patch-1", expected: "main-2/patch-1" },
{
existing: ["main-1/patch-1"],
preferred: "main/patch-1",
expected: "main-1/patch-1-2",
},
{
existing: ["bob/patch-1/child"],
preferred: "bob/patch-1",
expected: "bob/patch-1-1",
},
])(
"checks out a fork when $preferred conflicts with existing branch paths",
async ({ existing, preferred, expected }) => {
const git = (...args: string[]) =>
execFileSync("git", args, { cwd: repoDir, encoding: "utf8", stdio: "pipe" }).trim();
git("config", "core.autocrlf", "false");
const baseHead = git("rev-parse", "main");
for (const branch of existing) git("branch", branch);
git("checkout", "-b", "fixture-pr");
writeFileSync(join(repoDir, "file.txt"), "from-pr\n");
git("add", "file.txt");
git("-c", "commit.gpgsign=false", "commit", "-m", "fork patch");
const prHead = git("rev-parse", "HEAD");
git("checkout", "main");
const remote = join(tempDir, "origin.git");
git("clone", "--bare", repoDir, remote);
git("remote", "add", "origin", remote);
git(`--git-dir=${remote}`, "update-ref", "refs/pull/1/head", prHead);
git("pack-refs", "--all");
const result = await createWorktreePrimitive({
cwd: repoDir,
paseoHome,
worktreeSlug: "fork-review",
source: {
kind: "checkout-change-request",
forge: "gitea",
changeRequestNumber: 1,
headRef: "patch-1",
localBranchName: preferred,
baseRefName: "main",
checkoutRefs: [{ remoteName: "origin", remoteRef: "refs/pull/1/head" }],
},
});
expect(result.branchName).toBe(expected);
expect(git("rev-parse", result.branchName)).toBe(prHead);
expect(readFileSync(join(result.worktreePath, "file.txt"), "utf8")).toBe("from-pr\n");
for (const branch of ["main", ...existing]) {
expect(git("rev-parse", branch)).toBe(baseHead);
}
},
);
it("treats a worktree as paseo-owned even when its .git admin is missing", async () => {
const created = await createLegacyWorktreeForTest({
branchName: "orphan-admin-branch",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "orphan-admin",
paseoHome,
});
// Simulate a previous archive attempt that removed git's admin dir but left
// the working tree on disk (e.g. because file churn prevented full cleanup).
rmSync(join(repoDir, ".git", "worktrees", "orphan-admin"), {
recursive: true,
force: true,
});
expect(existsSync(created.worktreePath)).toBe(true);
const ownership = await isPaseoOwnedWorktreeCwd(created.worktreePath, { paseoHome });
expect(ownership).toMatchObject({ allowed: true, worktreePath: created.worktreePath });
// Windows may report the same root through native 8.3 and JS long spellings.
const nativeWorktreePath = realpathSync.native(created.worktreePath);
await expect(
runWorktreeTeardownCommands({
worktreePath: created.worktreePath,
teardownCwd: nativeWorktreePath,
}),
).resolves.toEqual([]);
await expect(
isPaseoOwnedWorktreeCwd(join(created.worktreePath, "packages", "app"), { paseoHome }),
).resolves.toMatchObject({
allowed: true,
worktreePath: created.worktreePath,
});
});
it("rejects paths that are not under the paseo worktrees root", async () => {
const outsidePath = join(tempDir, "outside-paseo-home");
mkdirSync(outsidePath, { recursive: true });
const ownership = await isPaseoOwnedWorktreeCwd(outsidePath, { paseoHome });
expect(ownership.allowed).toBe(false);
});
it("reports the source checkout root separately from Git's common directory", async () => {
const created = await createLegacyWorktreeForTest({
branchName: "placement-root-branch",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "placement-root",
paseoHome,
});
const ownership = await isPaseoOwnedWorktreeCwd(created.worktreePath, { paseoHome });
expect(ownership.allowed).toBe(true);
expect(createRealpathAwarePathMatcher(repoDir)(ownership.repoRoot ?? "")).toBe(true);
expect(createRealpathAwarePathMatcher(created.worktreePath)(ownership.worktreePath ?? "")).toBe(
true,
);
});
it("maps only root-contained workspace paths into a replacement worktree", () => {
const sourceWorktreePath = join(tempDir, "source-worktree");
const targetWorktreePath = join(tempDir, "target-worktree");
const nestedWorkspaceCwd = join(sourceWorktreePath, "packages", "app");
expect(
mapWorkspaceCwdToWorktree({
sourceWorktreePath,
workspaceCwd: sourceWorktreePath,
targetWorktreePath,
}),
).toBe(targetWorktreePath);
expect(
mapWorkspaceCwdToWorktree({
sourceWorktreePath,
workspaceCwd: nestedWorkspaceCwd,
targetWorktreePath,
}),
).toBe(join(targetWorktreePath, "packages", "app"));
expect(() =>
mapWorkspaceCwdToWorktree({
sourceWorktreePath,
workspaceCwd: join(tempDir, "outside-worktree"),
targetWorktreePath,
}),
).toThrow("outside its source worktree");
});
it.skipIf(process.platform === "win32")(
"maps a realpath-equivalent source workspace into the matching target subdirectory",
() => {
const sourceWorktreePath = join(tempDir, "source-worktree");
const workspaceCwd = join(sourceWorktreePath, "packages", "app");
const sourceAlias = join(tempDir, "source-alias");
const targetWorktreePath = join(tempDir, "target-worktree");
mkdirSync(workspaceCwd, { recursive: true });
symlinkSync(sourceWorktreePath, sourceAlias, "dir");
expect(
mapWorkspaceCwdToWorktree({
sourceWorktreePath: sourceAlias,
workspaceCwd,
targetWorktreePath,
}),
).toBe(join(targetWorktreePath, "packages", "app"));
},
);
it("rejects the worktrees root itself and the per-repo hash dir", async () => {
const projectHash = await deriveWorktreeProjectHash(repoDir);
const worktreesRoot = join(paseoHome, "worktrees");
const projectHashDir = join(worktreesRoot, projectHash);
mkdirSync(projectHashDir, { recursive: true });
await expect(isPaseoOwnedWorktreeCwd(worktreesRoot, { paseoHome })).resolves.toMatchObject({
allowed: false,
});
await expect(isPaseoOwnedWorktreeCwd(projectHashDir, { paseoHome })).resolves.toMatchObject({
allowed: false,
});
});
it("deletes a worktree whose .git admin dir has already been removed", async () => {
const created = await createLegacyWorktreeForTest({
branchName: "orphan-delete-branch",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "orphan-delete",
paseoHome,
});
rmSync(join(repoDir, ".git", "worktrees", "orphan-delete"), {
recursive: true,
force: true,
});
expect(existsSync(created.worktreePath)).toBe(true);
await deletePaseoWorktree({
cwd: repoDir,
worktreePath: created.worktreePath,
paseoHome,
});
expect(existsSync(created.worktreePath)).toBe(false);
});
it("deletes a worktree when Git reports its source through native and long path spellings", async () => {
const created = await createLegacyWorktreeForTest({
branchName: "identity-delete",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "identity-delete",
paseoHome,
});
await deletePaseoWorktree({
cwd: realpathSync.native(repoDir),
worktreePath: created.worktreePath,
paseoHome,
});
expect(existsSync(created.worktreePath)).toBe(false);
});
it("is idempotent: deleting an already-absent worktree succeeds", async () => {
const created = await createLegacyWorktreeForTest({
branchName: "idempotent-delete-branch",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "idempotent-delete",
paseoHome,
});
await deletePaseoWorktree({
cwd: repoDir,
worktreePath: created.worktreePath,
paseoHome,
});
expect(existsSync(created.worktreePath)).toBe(false);
// Second call — nothing left on disk and no admin entry — must not throw.
await expect(
deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome }),
).resolves.toBeUndefined();
});
it("deletes a worktree when the parent repo root is not available", async () => {
const created = await createLegacyWorktreeForTest({
branchName: "no-cwd-branch",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "no-cwd",
paseoHome,
});
const ownership = await isPaseoOwnedWorktreeCwd(created.worktreePath, { paseoHome });
expect(ownership.allowed).toBe(true);
expect(ownership.worktreeRoot).toBeTruthy();
// Simulate the handler path when git has forgotten about the worktree:
// caller forwards the path-derived worktreesRoot from the ownership check.
await deletePaseoWorktree({
cwd: null,
worktreePath: created.worktreePath,
worktreesRoot: ownership.worktreeRoot,
paseoHome,
});
expect(existsSync(created.worktreePath)).toBe(false);
});
describe("branch-off from a remote-tracking base", () => {
function git(args: string[], cwd: string): string {
return execFileSync("git", ["-c", "commit.gpgsign=false", ...args], {
cwd,
encoding: "utf8",
}).trim();
}
// The repo caches <remote>/main at "initial" while the remote itself moves one commit ahead,
// as when nothing has fetched since someone else pushed.
function pushPastCachedRemoteRef(remoteName: string): string {
const remoteDir = join(tempDir, "remote.git");
git(["clone", "--bare", "-q", repoDir, remoteDir], tempDir);
git(["remote", "add", remoteName, remoteDir], repoDir);
git(["fetch", "-q", remoteName], repoDir);
const pusherDir = join(tempDir, "pusher");
git(["clone", "-q", remoteDir, pusherDir], tempDir);
git(["config", "user.email", "test@test.com"], pusherDir);
git(["config", "user.name", "Test"], pusherDir);
writeFileSync(join(pusherDir, "file.txt"), "pushed after the last fetch\n");
git(["commit", "-q", "-am", "remote ahead"], pusherDir);
git(["push", "-q", "origin", "HEAD:main"], pusherDir);
return git(["rev-parse", "HEAD"], pusherDir);
}
it("starts the new branch at the remote tip, not the cached origin/main", async () => {
const remoteTip = pushPastCachedRemoteRef("origin");
const fetchRefspecs = git(["config", "--get-all", "remote.origin.fetch"], repoDir);
const created = await createLegacyWorktreeForTest({
branchName: "from-remote-tip",
cwd: repoDir,
baseBranch: "origin/main",
worktreeSlug: "from-remote-tip",
paseoHome,
});
expect(git(["rev-parse", "HEAD"], created.worktreePath)).toBe(remoteTip);
expect(git(["config", "--get-all", "remote.origin.fetch"], repoDir)).toBe(fetchRefspecs);
});
it("refreshes a base on a remote other than origin", async () => {
const remoteTip = pushPastCachedRemoteRef("team/upstream");
const created = await createLegacyWorktreeForTest({
branchName: "from-upstream-tip",
cwd: repoDir,
baseBranch: "refs/remotes/team/upstream/main",
worktreeSlug: "from-upstream-tip",
paseoHome,
});
expect(git(["rev-parse", "HEAD"], created.worktreePath)).toBe(remoteTip);
});
it("branches from the cached ref when the remote is unreachable", async () => {
pushPastCachedRemoteRef("origin");
const cachedTip = git(["rev-parse", "refs/remotes/origin/main"], repoDir);
git(["remote", "set-url", "origin", join(tempDir, "missing.git")], repoDir);
const created = await createLegacyWorktreeForTest({
branchName: "from-cached-ref",
cwd: repoDir,
baseBranch: "origin/main",
worktreeSlug: "from-cached-ref",
paseoHome,
});
expect(git(["rev-parse", "HEAD"], created.worktreePath)).toBe(cachedTip);
});
it("branches from the cached ref when the remote accepts but never answers", async () => {
pushPastCachedRemoteRef("origin");
const cachedTip = git(["rev-parse", "refs/remotes/origin/main"], repoDir);
const silentRemote = await startSilentRemote();
git(["remote", "set-url", "origin", silentRemote.url], repoDir);
try {
const startedAt = Date.now();
const created = await createLegacyWorktreeForTest({
branchName: "from-silent-remote",
cwd: repoDir,
baseBranch: "origin/main",
worktreeSlug: "from-silent-remote",
paseoHome,
});
// Clients give up on a create request after 60s, so the fallback has to land well before.
expect(Date.now() - startedAt).toBeLessThan(30_000);
expect(git(["rev-parse", "HEAD"], created.worktreePath)).toBe(cachedTip);
} finally {
await silentRemote.close();
}
}, 45_000);
});
});
describe("slugify", () => {
function expectValidHostnameLabel(label: string): void {
expect(label.length).toBeGreaterThan(0);
expect(label.length).toBeLessThanOrEqual(63);
expect(label).toMatch(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/);
}
it("converts to lowercase kebab-case", () => {
expect(slugify("Hello World")).toBe("hello-world");
expect(slugify("FOO_BAR")).toBe("foo-bar");
expect(slugify("My GREAT App")).toBe("my-great-app");
});
it("replaces dots with hyphens", () => {
expect(slugify("my.app")).toBe("my-app");
expect(slugify("v1.2.3")).toBe("v1-2-3");
});
it("collapses multiple consecutive spaces to one hyphen", () => {
expect(slugify("feature cool stuff")).toBe("feature-cool-stuff");
});
it("replaces slashes with hyphens", () => {
expect(slugify("feature/cool stuff")).toBe("feature-cool-stuff");
expect(slugify("owner/repo")).toBe("owner-repo");
});
it("strips unsupported unicode characters", () => {
expect(slugify("café")).toBe("caf");
expect(slugify("日本語")).toBe("");
});
it("removes leading and trailing punctuation", () => {
expect(slugify("-foo-")).toBe("foo");
expect(slugify("__bar__")).toBe("bar");
expect(slugify(".baz.")).toBe("baz");
});
it("truncates long strings at word boundary", () => {
const longInput =
"https-stackoverflow-com-questions-68349031-only-run-actions-on-non-draft-pull-request";
const result = slugify(longInput);
expect(result.length).toBeLessThanOrEqual(50);
expectValidHostnameLabel(result);
expect(result).toBe("https-stackoverflow-com-questions-68349031-only");
});
it("truncates without trailing hyphen when no word boundary", () => {
const longInput = "a".repeat(60);
const result = slugify(longInput);
expect(result.length).toBe(50);
expect(result.endsWith("-")).toBe(false);
expectValidHostnameLabel(result);
});
it("keeps very long names within the hostname label length limit", () => {
const result = slugify("Beta Build ".repeat(12));
expect(result.length).toBeLessThanOrEqual(63);
expectValidHostnameLabel(result);
});
it("returns empty when names collapse to empty", () => {
expect(slugify("---")).toBe("");
expect(slugify("***")).toBe("");
expect(slugify("日本語")).toBe("");
});
it("is idempotent for representative inputs", () => {
const inputs = [
"my.app",
"feature/cool stuff",
" Café Launch ",
"__bar__",
"Beta Build ".repeat(12),
"release***candidate",
];
for (const input of inputs) {
const slug = slugify(input);
expect(slugify(slug)).toBe(slug);
}
});
});