mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Server: drop the synchronous branch-name generator dep, use the worktree slug as the initial branch, and move first-agent branch auto-naming to an async post-creation step backed by a structured LLM call. Guards against renaming when the placeholder branch has already been changed. App: make workspace and worktree archive optimistic, rolling back the local hide if the daemon call fails, and suppress incoming workspace upserts while an archive is pending.
1290 lines
43 KiB
TypeScript
1290 lines
43 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
import {
|
|
BranchAlreadyCheckedOutError,
|
|
createWorktree as createWorktreePrimitive,
|
|
deriveWorktreeProjectHash,
|
|
deletePaseoWorktree,
|
|
getScriptConfigs,
|
|
getWorktreeSetupCommands,
|
|
getWorktreeTerminalSpecs,
|
|
getWorktreeTeardownCommands,
|
|
isServiceScript,
|
|
isPaseoOwnedWorktreeCwd,
|
|
listPaseoWorktrees,
|
|
readPaseoConfig,
|
|
resolveWorktreeRuntimeEnv,
|
|
type WorktreeSetupCommandProgressEvent,
|
|
runWorktreeSetupCommands,
|
|
slugify,
|
|
type CreateWorktreeOptions,
|
|
type WorktreeConfig,
|
|
} from "./worktree";
|
|
import type { PaseoConfig } from "./paseo-config-schema.js";
|
|
|
|
function loadConfigForTest(repoRoot: string): PaseoConfig | null {
|
|
const result = readPaseoConfig(repoRoot);
|
|
return result.ok ? result.config : null;
|
|
}
|
|
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
|
|
import { execSync } from "child_process";
|
|
import {
|
|
mkdtempSync,
|
|
mkdirSync,
|
|
rmSync,
|
|
existsSync,
|
|
realpathSync,
|
|
writeFileSync,
|
|
readFileSync,
|
|
} from "fs";
|
|
import { dirname, join } from "path";
|
|
import { tmpdir } from "os";
|
|
import net from "node:net";
|
|
|
|
interface LegacyCreateWorktreeTestOptions {
|
|
branchName: string;
|
|
cwd: string;
|
|
baseBranch: string;
|
|
worktreeSlug: string;
|
|
runSetup?: boolean;
|
|
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,
|
|
},
|
|
runSetup: options.runSetup ?? true,
|
|
paseoHome: options.paseoHome,
|
|
});
|
|
}
|
|
|
|
describe.skipIf(process.platform === "win32")("createWorktree", () => {
|
|
let tempDir: string;
|
|
let repoDir: string;
|
|
let paseoHome: string;
|
|
|
|
beforeEach(() => {
|
|
// Use realpathSync to resolve symlinks (e.g., /var -> /private/var on macOS)
|
|
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "worktree-test-")));
|
|
repoDir = join(tempDir, "test-repo");
|
|
paseoHome = join(tempDir, "paseo-home");
|
|
|
|
// Create a git repo with an initial commit
|
|
mkdirSync(repoDir, { recursive: true });
|
|
execSync("git init -b main", { cwd: repoDir });
|
|
execSync('git config user.email "test@test.com"', { cwd: repoDir });
|
|
execSync('git config user.name "Test"', { cwd: repoDir });
|
|
execSync('echo "hello" > file.txt', { cwd: repoDir });
|
|
execSync("git add .", { cwd: repoDir });
|
|
execSync('git -c commit.gpgsign=false commit -m "initial"', { cwd: repoDir });
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("creates a worktree for the current branch (main)", async () => {
|
|
const projectHash = await deriveWorktreeProjectHash(repoDir);
|
|
const result = await createLegacyWorktreeForTest({
|
|
branchName: "main",
|
|
cwd: repoDir,
|
|
baseBranch: "main",
|
|
worktreeSlug: "hello-world",
|
|
paseoHome,
|
|
});
|
|
|
|
expect(result.worktreePath).toBe(join(paseoHome, "worktrees", projectHash, "hello-world"));
|
|
expect(existsSync(result.worktreePath)).toBe(true);
|
|
expect(existsSync(join(result.worktreePath, "file.txt"))).toBe(true);
|
|
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
|
|
expect(existsSync(metadataPath)).toBe(true);
|
|
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
|
|
expect(metadata).toMatchObject({ version: 1, baseRefName: "main" });
|
|
});
|
|
|
|
it.skip("detects paseo-owned worktrees across realpath differences (macOS /var vs /private/var)", async () => {
|
|
// Intentionally create repo using the non-realpath tmpdir() variant (often /var/... on macOS).
|
|
const varTempDir = mkdtempSync(join(tmpdir(), "worktree-realpath-test-"));
|
|
const privateTempDir = realpathSync(varTempDir);
|
|
const varRepoDir = join(varTempDir, "test-repo");
|
|
const varPaseoHome = join(varTempDir, "paseo-home");
|
|
mkdirSync(varRepoDir, { recursive: true });
|
|
execSync("git init -b main", { cwd: varRepoDir });
|
|
execSync('git config user.email "test@test.com"', { cwd: varRepoDir });
|
|
execSync('git config user.name "Test"', { cwd: varRepoDir });
|
|
execSync('echo "hello" > file.txt', { cwd: varRepoDir });
|
|
execSync("git add .", { cwd: varRepoDir });
|
|
execSync('git -c commit.gpgsign=false commit -m "initial"', { cwd: varRepoDir });
|
|
|
|
await createLegacyWorktreeForTest({
|
|
branchName: "main",
|
|
cwd: varRepoDir,
|
|
baseBranch: "main",
|
|
worktreeSlug: "realpath-test",
|
|
paseoHome: varPaseoHome,
|
|
});
|
|
|
|
const projectHash = await deriveWorktreeProjectHash(varRepoDir);
|
|
const privateWorktreePath = join(
|
|
privateTempDir,
|
|
"paseo-home",
|
|
"worktrees",
|
|
projectHash,
|
|
"realpath-test",
|
|
);
|
|
expect(existsSync(privateWorktreePath)).toBe(true);
|
|
|
|
const ownership = await isPaseoOwnedWorktreeCwd(privateWorktreePath, {
|
|
paseoHome: varPaseoHome,
|
|
});
|
|
expect(ownership.allowed).toBe(true);
|
|
|
|
rmSync(varTempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("reports repoRoot as the repository root for paseo-owned worktrees", async () => {
|
|
const result = await createLegacyWorktreeForTest({
|
|
branchName: "main",
|
|
cwd: repoDir,
|
|
baseBranch: "main",
|
|
worktreeSlug: "repo-root-check",
|
|
paseoHome,
|
|
});
|
|
|
|
const ownership = await isPaseoOwnedWorktreeCwd(result.worktreePath, { paseoHome });
|
|
expect(ownership.allowed).toBe(true);
|
|
expect(ownership.repoRoot).toBe(repoDir);
|
|
});
|
|
|
|
it("treats non-git directories as non-worktrees without throwing", async () => {
|
|
const nonGitDir = join(tempDir, "not-a-repo");
|
|
mkdirSync(nonGitDir, { recursive: true });
|
|
|
|
const ownership = await isPaseoOwnedWorktreeCwd(nonGitDir, { paseoHome });
|
|
|
|
expect(ownership.allowed).toBe(false);
|
|
expect(ownership.worktreePath).toBe(realpathSync(nonGitDir));
|
|
});
|
|
|
|
it("creates a worktree with a new branch", async () => {
|
|
const projectHash = await deriveWorktreeProjectHash(repoDir);
|
|
const result = await createLegacyWorktreeForTest({
|
|
cwd: repoDir,
|
|
worktreeSlug: "my-feature",
|
|
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/x" },
|
|
runSetup: true,
|
|
paseoHome,
|
|
});
|
|
|
|
expect(result.worktreePath).toBe(join(paseoHome, "worktrees", projectHash, "my-feature"));
|
|
expect(existsSync(result.worktreePath)).toBe(true);
|
|
|
|
const currentBranch = execSync("git branch --show-current", {
|
|
cwd: result.worktreePath,
|
|
})
|
|
.toString()
|
|
.trim();
|
|
expect(currentBranch).toBe("feature/x");
|
|
execSync("git merge-base --is-ancestor main HEAD", { cwd: result.worktreePath });
|
|
|
|
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
|
|
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
|
|
expect(metadata).toMatchObject({ version: 1, baseRefName: "main" });
|
|
});
|
|
|
|
it("checks out an existing local branch that is not checked out elsewhere", async () => {
|
|
execSync("git branch dev", { cwd: repoDir });
|
|
|
|
const result = await createLegacyWorktreeForTest({
|
|
cwd: repoDir,
|
|
worktreeSlug: "dev-worktree",
|
|
source: { kind: "checkout-branch", branchName: "dev" },
|
|
runSetup: true,
|
|
paseoHome,
|
|
});
|
|
|
|
expect(existsSync(result.worktreePath)).toBe(true);
|
|
const currentBranch = execSync("git branch --show-current", {
|
|
cwd: result.worktreePath,
|
|
})
|
|
.toString()
|
|
.trim();
|
|
expect(currentBranch).toBe("dev");
|
|
|
|
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
|
|
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
|
|
expect(metadata).toMatchObject({ version: 1, baseRefName: "dev" });
|
|
});
|
|
|
|
it("throws a typed error when checking out a branch already checked out in the main repo", async () => {
|
|
let caughtError: unknown;
|
|
try {
|
|
await createLegacyWorktreeForTest({
|
|
cwd: repoDir,
|
|
worktreeSlug: "dev-worktree",
|
|
source: { kind: "checkout-branch", branchName: "main" },
|
|
runSetup: true,
|
|
paseoHome,
|
|
});
|
|
} catch (error) {
|
|
caughtError = error;
|
|
}
|
|
|
|
expect(caughtError).toBeInstanceOf(BranchAlreadyCheckedOutError);
|
|
expect((caughtError as BranchAlreadyCheckedOutError).branchName).toBe("main");
|
|
});
|
|
|
|
it("fetches a GitHub PR branch, checks it out, writes metadata, and runs setup", async () => {
|
|
const remoteDir = join(tempDir, "remote.git");
|
|
const remoteCloneDir = join(tempDir, "remote-clone");
|
|
execSync(`git clone --bare ${repoDir} ${remoteDir}`);
|
|
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
|
|
|
|
execSync(`git clone ${remoteDir} ${remoteCloneDir}`);
|
|
execSync('git config user.email "test@test.com"', { cwd: remoteCloneDir });
|
|
execSync('git config user.name "Test"', { cwd: remoteCloneDir });
|
|
execSync("git checkout -b contributor/feature", { cwd: remoteCloneDir });
|
|
writeFileSync(join(remoteCloneDir, "file.txt"), "from-pr\n");
|
|
writeFileSync(
|
|
join(remoteCloneDir, "paseo.json"),
|
|
JSON.stringify({ worktree: { setup: ['echo "setup ran" > setup.log'] } }),
|
|
);
|
|
execSync("git add .", { cwd: remoteCloneDir });
|
|
execSync('git -c commit.gpgsign=false commit -m "pr branch"', { cwd: remoteCloneDir });
|
|
const prHead = execSync("git rev-parse HEAD", { cwd: remoteCloneDir }).toString().trim();
|
|
execSync("git push origin contributor/feature", { cwd: remoteCloneDir });
|
|
execSync(`git --git-dir=${remoteDir} update-ref refs/pull/42/head ${prHead}`);
|
|
|
|
const result = await createLegacyWorktreeForTest({
|
|
cwd: repoDir,
|
|
worktreeSlug: "pr-42",
|
|
source: {
|
|
kind: "checkout-github-pr",
|
|
githubPrNumber: 42,
|
|
headRef: "user/feature",
|
|
baseRefName: "main",
|
|
},
|
|
runSetup: true,
|
|
paseoHome,
|
|
});
|
|
|
|
expect(readFileSync(join(result.worktreePath, "file.txt"), "utf8")).toBe("from-pr\n");
|
|
expect(readFileSync(join(result.worktreePath, "setup.log"), "utf8")).toBe("setup ran\n");
|
|
const currentBranch = execSync("git branch --show-current", {
|
|
cwd: result.worktreePath,
|
|
})
|
|
.toString()
|
|
.trim();
|
|
expect(currentBranch).toBe("user/feature");
|
|
|
|
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
|
|
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
|
|
expect(metadata).toMatchObject({ baseRefName: "main" });
|
|
});
|
|
|
|
it("prefers origin/{branch} over local {branch} when both exist", async () => {
|
|
const remoteDir = join(tempDir, "remote.git");
|
|
const remoteCloneDir = join(tempDir, "remote-clone");
|
|
execSync(`git init --bare ${remoteDir}`);
|
|
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
|
|
execSync("git push -u origin main", { cwd: repoDir });
|
|
|
|
execSync(`git clone ${remoteDir} ${remoteCloneDir}`);
|
|
execSync('git config user.email "test@test.com"', { cwd: remoteCloneDir });
|
|
execSync('git config user.name "Test"', { cwd: remoteCloneDir });
|
|
execSync("git checkout -B main origin/main", { cwd: remoteCloneDir });
|
|
writeFileSync(join(remoteCloneDir, "file.txt"), "from-origin\n");
|
|
execSync("git add file.txt", { cwd: remoteCloneDir });
|
|
execSync('git -c commit.gpgsign=false commit -m "advance origin main"', {
|
|
cwd: remoteCloneDir,
|
|
});
|
|
execSync("git push origin main", { cwd: remoteCloneDir });
|
|
|
|
writeFileSync(join(repoDir, "file.txt"), "from-local\n");
|
|
execSync("git add file.txt", { cwd: repoDir });
|
|
execSync('git -c commit.gpgsign=false commit -m "advance local main"', { cwd: repoDir });
|
|
|
|
execSync("git fetch origin", { cwd: repoDir });
|
|
|
|
const result = await createLegacyWorktreeForTest({
|
|
branchName: "prefer-origin-feature",
|
|
cwd: repoDir,
|
|
baseBranch: "main",
|
|
worktreeSlug: "prefer-origin-feature",
|
|
runSetup: false,
|
|
paseoHome,
|
|
});
|
|
|
|
expect(readFileSync(join(result.worktreePath, "file.txt"), "utf8")).toBe("from-origin\n");
|
|
});
|
|
|
|
it("falls back to local {branch} when origin/{branch} does not exist", async () => {
|
|
writeFileSync(join(repoDir, "file.txt"), "from-local-only\n");
|
|
execSync("git add file.txt", { cwd: repoDir });
|
|
execSync('git -c commit.gpgsign=false commit -m "advance local main only"', { cwd: repoDir });
|
|
|
|
const result = await createLegacyWorktreeForTest({
|
|
branchName: "prefer-local-fallback-feature",
|
|
cwd: repoDir,
|
|
baseBranch: "main",
|
|
worktreeSlug: "prefer-local-fallback-feature",
|
|
runSetup: false,
|
|
paseoHome,
|
|
});
|
|
|
|
expect(readFileSync(join(result.worktreePath, "file.txt"), "utf8")).toBe("from-local-only\n");
|
|
});
|
|
|
|
it("throws when neither origin/{branch} nor local {branch} exists", async () => {
|
|
await expect(
|
|
createLegacyWorktreeForTest({
|
|
branchName: "missing-base-feature",
|
|
cwd: repoDir,
|
|
baseBranch: "does-not-exist",
|
|
worktreeSlug: "missing-base-feature",
|
|
runSetup: false,
|
|
paseoHome,
|
|
}),
|
|
).rejects.toThrow("Base branch not found: does-not-exist");
|
|
});
|
|
|
|
it("fails with invalid branch name", async () => {
|
|
await expect(
|
|
createLegacyWorktreeForTest({
|
|
branchName: "INVALID_UPPERCASE",
|
|
cwd: repoDir,
|
|
baseBranch: "main",
|
|
worktreeSlug: "test",
|
|
}),
|
|
).rejects.toThrow("Invalid branch name");
|
|
});
|
|
|
|
it("handles branch name collision by adding suffix", async () => {
|
|
const projectHash = await deriveWorktreeProjectHash(repoDir);
|
|
// Create a branch named "hello" first
|
|
execSync("git branch hello", { cwd: repoDir });
|
|
|
|
const result = await createLegacyWorktreeForTest({
|
|
branchName: "main",
|
|
cwd: repoDir,
|
|
baseBranch: "main",
|
|
worktreeSlug: "hello",
|
|
paseoHome,
|
|
});
|
|
|
|
// Should create branch "hello-1" since "hello" exists
|
|
expect(result.worktreePath).toBe(join(paseoHome, "worktrees", projectHash, "hello"));
|
|
expect(existsSync(result.worktreePath)).toBe(true);
|
|
|
|
const branches = execSync("git branch", { cwd: repoDir }).toString();
|
|
expect(branches).toContain("hello-1");
|
|
});
|
|
|
|
it("handles multiple collisions", async () => {
|
|
// Create branches "hello" and "hello-1"
|
|
execSync("git branch hello", { cwd: repoDir });
|
|
execSync("git branch hello-1", { cwd: repoDir });
|
|
|
|
const result = await createLegacyWorktreeForTest({
|
|
branchName: "main",
|
|
cwd: repoDir,
|
|
baseBranch: "main",
|
|
worktreeSlug: "hello",
|
|
paseoHome,
|
|
});
|
|
|
|
expect(existsSync(result.worktreePath)).toBe(true);
|
|
|
|
const branches = execSync("git branch", { cwd: repoDir }).toString();
|
|
expect(branches).toContain("hello-2");
|
|
});
|
|
|
|
it("runs setup commands from paseo.json", async () => {
|
|
// Create paseo.json with setup commands
|
|
const paseoConfig = {
|
|
worktree: {
|
|
setup: [
|
|
'echo "source=$PASEO_SOURCE_CHECKOUT_PATH" > setup.log',
|
|
'echo "root_alias=$PASEO_ROOT_PATH" >> setup.log',
|
|
'echo "worktree=$PASEO_WORKTREE_PATH" >> setup.log',
|
|
'echo "branch=$PASEO_BRANCH_NAME" >> setup.log',
|
|
'echo "port=$PASEO_WORKTREE_PORT" >> setup.log',
|
|
],
|
|
},
|
|
};
|
|
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
|
|
execSync('git add paseo.json && git -c commit.gpgsign=false commit -m "add paseo.json"', {
|
|
cwd: repoDir,
|
|
});
|
|
|
|
const result = await createLegacyWorktreeForTest({
|
|
branchName: "main",
|
|
cwd: repoDir,
|
|
baseBranch: "main",
|
|
worktreeSlug: "setup-test",
|
|
paseoHome,
|
|
});
|
|
|
|
expect(existsSync(result.worktreePath)).toBe(true);
|
|
|
|
// Verify setup ran and env vars were available
|
|
const setupLog = readFileSync(join(result.worktreePath, "setup.log"), "utf8");
|
|
expect(setupLog).toContain(`source=${repoDir}`);
|
|
expect(setupLog).toContain(`root_alias=${repoDir}`);
|
|
expect(setupLog).toContain(`worktree=${result.worktreePath}`);
|
|
expect(setupLog).toContain("branch=setup-test");
|
|
const portLine = setupLog.split("\n").find((line) => line.startsWith("port="));
|
|
expect(portLine).toBeDefined();
|
|
const portValue = Number(portLine?.slice("port=".length));
|
|
expect(Number.isInteger(portValue)).toBe(true);
|
|
expect(portValue).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("runs string setup scripts from paseo.json as a single shell command", async () => {
|
|
const paseoConfig = {
|
|
worktree: {
|
|
setup: 'greeting="hello from string setup"\necho "$greeting" > setup.log',
|
|
},
|
|
};
|
|
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
|
|
execSync('git add paseo.json && git -c commit.gpgsign=false commit -m "add string setup"', {
|
|
cwd: repoDir,
|
|
});
|
|
|
|
const result = await createLegacyWorktreeForTest({
|
|
branchName: "main",
|
|
cwd: repoDir,
|
|
baseBranch: "main",
|
|
worktreeSlug: "string-setup-test",
|
|
paseoHome,
|
|
});
|
|
|
|
expect(getWorktreeSetupCommands(result.worktreePath)).toEqual([
|
|
'greeting="hello from string setup"\necho "$greeting" > setup.log',
|
|
]);
|
|
expect(readFileSync(join(result.worktreePath, "setup.log"), "utf8").trim()).toBe(
|
|
"hello from string setup",
|
|
);
|
|
});
|
|
|
|
it("treats blank lifecycle strings as empty", () => {
|
|
writeFileSync(
|
|
join(repoDir, "paseo.json"),
|
|
JSON.stringify({
|
|
worktree: {
|
|
setup: " \n\t ",
|
|
teardown: " \n ",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(getWorktreeSetupCommands(repoDir)).toEqual([]);
|
|
expect(getWorktreeTeardownCommands(repoDir)).toEqual([]);
|
|
});
|
|
|
|
it("filters non-string and blank entries from lifecycle arrays", () => {
|
|
writeFileSync(
|
|
join(repoDir, "paseo.json"),
|
|
JSON.stringify({
|
|
worktree: {
|
|
setup: [
|
|
'echo "first" > setup-array.log',
|
|
null,
|
|
" ",
|
|
'echo "second" >> setup-array.log',
|
|
],
|
|
teardown: [
|
|
'echo "first" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown-array.log"',
|
|
null,
|
|
"",
|
|
'echo "second" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown-array.log"',
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(getWorktreeSetupCommands(repoDir)).toEqual([
|
|
'echo "first" > setup-array.log',
|
|
'echo "second" >> setup-array.log',
|
|
]);
|
|
expect(getWorktreeTeardownCommands(repoDir)).toEqual([
|
|
'echo "first" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown-array.log"',
|
|
'echo "second" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown-array.log"',
|
|
]);
|
|
});
|
|
|
|
it("does not run setup commands when runSetup=false", async () => {
|
|
const paseoConfig = {
|
|
worktree: {
|
|
setup: ['echo "setup ran" > setup.log'],
|
|
},
|
|
};
|
|
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
|
|
execSync('git add paseo.json && git -c commit.gpgsign=false commit -m "add paseo.json"', {
|
|
cwd: repoDir,
|
|
});
|
|
|
|
const result = await createLegacyWorktreeForTest({
|
|
branchName: "main",
|
|
cwd: repoDir,
|
|
baseBranch: "main",
|
|
worktreeSlug: "no-setup-test",
|
|
runSetup: false,
|
|
paseoHome,
|
|
});
|
|
|
|
expect(existsSync(result.worktreePath)).toBe(true);
|
|
expect(existsSync(join(result.worktreePath, "setup.log"))).toBe(false);
|
|
});
|
|
|
|
it("streams setup command progress events while commands are executing", async () => {
|
|
const paseoConfig = {
|
|
worktree: {
|
|
setup: ['echo "first line"; echo "second line" 1>&2'],
|
|
},
|
|
};
|
|
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
|
|
execSync('git add paseo.json && git -c commit.gpgsign=false commit -m "add streaming setup"', {
|
|
cwd: repoDir,
|
|
});
|
|
|
|
const progressEvents: WorktreeSetupCommandProgressEvent[] = [];
|
|
const results = await runWorktreeSetupCommands({
|
|
worktreePath: repoDir,
|
|
branchName: "main",
|
|
cleanupOnFailure: false,
|
|
onEvent: (event) => {
|
|
progressEvents.push(event);
|
|
},
|
|
});
|
|
|
|
expect(results).toHaveLength(1);
|
|
expect(progressEvents.some((event) => event.type === "command_started")).toBe(true);
|
|
expect(progressEvents.some((event) => event.type === "output")).toBe(true);
|
|
expect(progressEvents.some((event) => event.type === "command_completed")).toBe(true);
|
|
});
|
|
|
|
it("reuses persisted worktree runtime port across resolutions", async () => {
|
|
const result = await createLegacyWorktreeForTest({
|
|
branchName: "main",
|
|
cwd: repoDir,
|
|
baseBranch: "main",
|
|
worktreeSlug: "runtime-env-port-reuse",
|
|
runSetup: false,
|
|
paseoHome,
|
|
});
|
|
|
|
const first = await resolveWorktreeRuntimeEnv({
|
|
worktreePath: result.worktreePath,
|
|
branchName: result.branchName,
|
|
});
|
|
const second = await resolveWorktreeRuntimeEnv({
|
|
worktreePath: result.worktreePath,
|
|
branchName: result.branchName,
|
|
});
|
|
|
|
expect(second.PASEO_WORKTREE_PORT).toBe(first.PASEO_WORKTREE_PORT);
|
|
});
|
|
|
|
it("fails runtime env resolution when persisted port is in use", async () => {
|
|
const result = await createLegacyWorktreeForTest({
|
|
branchName: "main",
|
|
cwd: repoDir,
|
|
baseBranch: "main",
|
|
worktreeSlug: "runtime-env-port-conflict",
|
|
runSetup: false,
|
|
paseoHome,
|
|
});
|
|
|
|
const env = await resolveWorktreeRuntimeEnv({
|
|
worktreePath: result.worktreePath,
|
|
branchName: result.branchName,
|
|
});
|
|
const port = Number(env.PASEO_WORKTREE_PORT);
|
|
|
|
const server = net.createServer();
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.once("error", reject);
|
|
server.listen(port, () => resolve());
|
|
});
|
|
|
|
await expect(
|
|
resolveWorktreeRuntimeEnv({
|
|
worktreePath: result.worktreePath,
|
|
branchName: result.branchName,
|
|
}),
|
|
).rejects.toThrow(`Persisted worktree port ${port} is already in use`);
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.close((error) => {
|
|
if (error) {
|
|
reject(error);
|
|
return;
|
|
}
|
|
resolve();
|
|
});
|
|
});
|
|
});
|
|
|
|
it("cleans up worktree if setup command fails", async () => {
|
|
// Create paseo.json with failing setup command
|
|
const paseoConfig = {
|
|
worktree: {
|
|
setup: ["exit 1"],
|
|
},
|
|
};
|
|
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
|
|
execSync('git add paseo.json && git -c commit.gpgsign=false commit -m "add paseo.json"', {
|
|
cwd: repoDir,
|
|
});
|
|
|
|
const expectedWorktreePath = join(paseoHome, "worktrees", "test-repo", "fail-test");
|
|
|
|
await expect(
|
|
createLegacyWorktreeForTest({
|
|
branchName: "main",
|
|
cwd: repoDir,
|
|
baseBranch: "main",
|
|
worktreeSlug: "fail-test",
|
|
paseoHome,
|
|
}),
|
|
).rejects.toThrow("Worktree setup command failed");
|
|
|
|
// Verify worktree was cleaned up
|
|
expect(existsSync(expectedWorktreePath)).toBe(false);
|
|
});
|
|
|
|
it("reads worktree terminal specs from paseo.json with optional name", async () => {
|
|
const paseoConfig = {
|
|
worktree: {
|
|
terminals: [
|
|
{ name: "Dev Server", command: "npm run dev" },
|
|
{ command: "cd packages/app && npm run dev" },
|
|
],
|
|
},
|
|
};
|
|
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
|
|
|
|
expect(getWorktreeTerminalSpecs(repoDir)).toEqual([
|
|
{ name: "Dev Server", command: "npm run dev" },
|
|
{ command: "cd packages/app && npm run dev" },
|
|
]);
|
|
});
|
|
|
|
it("filters invalid worktree terminal specs", async () => {
|
|
const paseoConfig = {
|
|
worktree: {
|
|
terminals: [
|
|
null,
|
|
{},
|
|
{ name: " ", command: " " },
|
|
{ name: " Watch ", command: "npm run watch", cwd: "packages/app" },
|
|
{ name: 123, command: "npm run test" },
|
|
],
|
|
},
|
|
};
|
|
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
|
|
|
|
expect(getWorktreeTerminalSpecs(repoDir)).toEqual([
|
|
{ name: "Watch", command: "npm run watch" },
|
|
{ command: "npm run test" },
|
|
]);
|
|
});
|
|
|
|
it("parses omitted script type as a plain script", async () => {
|
|
writeFileSync(
|
|
join(repoDir, "paseo.json"),
|
|
JSON.stringify({
|
|
scripts: {
|
|
typecheck: {
|
|
command: " npm run typecheck ",
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
const scriptConfigs = getScriptConfigs(loadConfigForTest(repoDir));
|
|
const typecheck = scriptConfigs.get("typecheck");
|
|
|
|
expect(typecheck).toEqual({
|
|
command: "npm run typecheck",
|
|
});
|
|
expect(typecheck).toBeDefined();
|
|
expect(isServiceScript(typecheck!)).toBe(false);
|
|
});
|
|
|
|
it("parses service scripts and preserves optional port", async () => {
|
|
writeFileSync(
|
|
join(repoDir, "paseo.json"),
|
|
JSON.stringify({
|
|
scripts: {
|
|
server: {
|
|
type: "service",
|
|
command: "npm run dev",
|
|
port: 4321,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
const scriptConfigs = getScriptConfigs(loadConfigForTest(repoDir));
|
|
const server = scriptConfigs.get("server");
|
|
|
|
expect(server).toEqual({
|
|
type: "service",
|
|
command: "npm run dev",
|
|
port: 4321,
|
|
});
|
|
expect(server).toBeDefined();
|
|
expect(isServiceScript(server!)).toBe(true);
|
|
});
|
|
|
|
it("ignores invalid script entries gracefully", async () => {
|
|
writeFileSync(
|
|
join(repoDir, "paseo.json"),
|
|
JSON.stringify({
|
|
scripts: {
|
|
valid: {
|
|
command: "npm run valid",
|
|
},
|
|
invalidType: {
|
|
type: "worker",
|
|
command: "npm run worker",
|
|
},
|
|
missingCommand: {
|
|
type: "service",
|
|
},
|
|
blankCommand: {
|
|
command: " ",
|
|
},
|
|
nonObject: "npm run nope",
|
|
invalidPort: {
|
|
type: "service",
|
|
command: "npm run dev",
|
|
port: "3000",
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(getScriptConfigs(loadConfigForTest(repoDir))).toEqual(
|
|
new Map([
|
|
["valid", { command: "npm run valid" }],
|
|
["invalidType", { command: "npm run worker" }],
|
|
["invalidPort", { type: "service", command: "npm run dev" }],
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("seeds an uncommitted paseo.json from the main repo into a new worktree", async () => {
|
|
writeFileSync(
|
|
join(repoDir, "paseo.json"),
|
|
JSON.stringify({ scripts: { dev: { command: "echo hi" } } }),
|
|
);
|
|
|
|
const result = await createLegacyWorktreeForTest({
|
|
cwd: repoDir,
|
|
worktreeSlug: "seed-uncommitted",
|
|
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/seed" },
|
|
runSetup: false,
|
|
paseoHome,
|
|
});
|
|
|
|
const worktreeConfigPath = join(result.worktreePath, "paseo.json");
|
|
expect(existsSync(worktreeConfigPath)).toBe(true);
|
|
expect(JSON.parse(readFileSync(worktreeConfigPath, "utf8"))).toEqual({
|
|
scripts: { dev: { command: "echo hi" } },
|
|
});
|
|
});
|
|
|
|
it("does not overwrite a committed paseo.json with uncommitted edits in the main repo", async () => {
|
|
writeFileSync(
|
|
join(repoDir, "paseo.json"),
|
|
JSON.stringify({ scripts: { dev: { command: "committed" } } }),
|
|
);
|
|
execSync("git add paseo.json", { cwd: repoDir });
|
|
execSync('git -c commit.gpgsign=false commit -m "add paseo.json"', { cwd: repoDir });
|
|
|
|
writeFileSync(
|
|
join(repoDir, "paseo.json"),
|
|
JSON.stringify({ scripts: { dev: { command: "uncommitted" } } }),
|
|
);
|
|
|
|
const result = await createLegacyWorktreeForTest({
|
|
cwd: repoDir,
|
|
worktreeSlug: "preserve-committed",
|
|
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/preserve" },
|
|
runSetup: false,
|
|
paseoHome,
|
|
});
|
|
|
|
const worktreeConfigPath = join(result.worktreePath, "paseo.json");
|
|
expect(JSON.parse(readFileSync(worktreeConfigPath, "utf8"))).toEqual({
|
|
scripts: { dev: { command: "committed" } },
|
|
});
|
|
});
|
|
|
|
it("creates a worktree without error when no paseo.json exists in the main repo", async () => {
|
|
const result = await createLegacyWorktreeForTest({
|
|
cwd: repoDir,
|
|
worktreeSlug: "no-config",
|
|
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/no-config" },
|
|
runSetup: false,
|
|
paseoHome,
|
|
});
|
|
|
|
expect(existsSync(join(result.worktreePath, "paseo.json"))).toBe(false);
|
|
});
|
|
});
|
|
|
|
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 });
|
|
execSync("git init -b main", { cwd: repoDir });
|
|
execSync('git config user.email "test@test.com"', { cwd: repoDir });
|
|
execSync('git config user.name "Test"', { cwd: repoDir });
|
|
execSync('echo "hello" > file.txt', { cwd: repoDir });
|
|
execSync("git add .", { cwd: repoDir });
|
|
execSync('git -c commit.gpgsign=false commit -m "initial"', { cwd: repoDir });
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("isolates worktree roots for repositories that share the same directory name", async () => {
|
|
const repoA = join(tempDir, "team-a", "test-repo");
|
|
const repoB = join(tempDir, "team-b", "test-repo");
|
|
|
|
for (const repo of [repoA, repoB]) {
|
|
mkdirSync(repo, { recursive: true });
|
|
execSync("git init -b main", { cwd: repo });
|
|
execSync('git config user.email "test@test.com"', { cwd: repo });
|
|
execSync('git config user.name "Test"', { cwd: repo });
|
|
execSync('echo "hello" > file.txt', { cwd: repo });
|
|
execSync("git add .", { cwd: repo });
|
|
execSync('git -c commit.gpgsign=false commit -m "initial"', { cwd: repo });
|
|
}
|
|
|
|
const fromRepoA = await createLegacyWorktreeForTest({
|
|
branchName: "main",
|
|
cwd: repoA,
|
|
baseBranch: "main",
|
|
worktreeSlug: "alpha",
|
|
paseoHome,
|
|
});
|
|
const fromRepoB = await createLegacyWorktreeForTest({
|
|
branchName: "main",
|
|
cwd: repoB,
|
|
baseBranch: "main",
|
|
worktreeSlug: "alpha",
|
|
paseoHome,
|
|
});
|
|
|
|
expect(dirname(fromRepoA.worktreePath)).not.toBe(dirname(fromRepoB.worktreePath));
|
|
expect(fromRepoA.worktreePath.endsWith("alpha-1")).toBe(false);
|
|
expect(fromRepoB.worktreePath.endsWith("alpha-1")).toBe(false);
|
|
|
|
const repoAWorktrees = await listPaseoWorktrees({ cwd: repoA, paseoHome });
|
|
const repoBWorktrees = await listPaseoWorktrees({ cwd: repoB, paseoHome });
|
|
|
|
expect(repoAWorktrees.map((entry) => entry.path)).toEqual([fromRepoA.worktreePath]);
|
|
expect(repoBWorktrees.map((entry) => entry.path)).toEqual([fromRepoB.worktreePath]);
|
|
});
|
|
|
|
it("lists and deletes paseo worktrees under ~/.paseo/worktrees/{hash}", async () => {
|
|
const first = await createLegacyWorktreeForTest({
|
|
branchName: "main",
|
|
cwd: repoDir,
|
|
baseBranch: "main",
|
|
worktreeSlug: "alpha",
|
|
paseoHome,
|
|
});
|
|
const second = await createLegacyWorktreeForTest({
|
|
branchName: "main",
|
|
cwd: repoDir,
|
|
baseBranch: "main",
|
|
worktreeSlug: "beta",
|
|
paseoHome,
|
|
});
|
|
|
|
const worktrees = await listPaseoWorktrees({ cwd: repoDir, paseoHome });
|
|
const paths = worktrees.map((worktree) => worktree.path).sort();
|
|
expect(paths).toEqual([first.worktreePath, second.worktreePath].sort());
|
|
|
|
await deletePaseoWorktree({ cwd: repoDir, worktreePath: first.worktreePath, paseoHome });
|
|
expect(existsSync(first.worktreePath)).toBe(false);
|
|
|
|
const remaining = await listPaseoWorktrees({ cwd: repoDir, paseoHome });
|
|
expect(remaining.map((worktree) => worktree.path)).toEqual([second.worktreePath]);
|
|
});
|
|
|
|
it("deletes a paseo worktree even when given a subdirectory path", async () => {
|
|
const created = await createLegacyWorktreeForTest({
|
|
branchName: "main",
|
|
cwd: repoDir,
|
|
baseBranch: "main",
|
|
worktreeSlug: "alpha",
|
|
paseoHome,
|
|
});
|
|
|
|
const nestedDir = join(created.worktreePath, "nested", "dir");
|
|
mkdirSync(nestedDir, { recursive: true });
|
|
|
|
await deletePaseoWorktree({ cwd: repoDir, worktreePath: nestedDir, paseoHome });
|
|
expect(existsSync(created.worktreePath)).toBe(false);
|
|
|
|
const remaining = await listPaseoWorktrees({ cwd: repoDir, paseoHome });
|
|
expect(remaining.some((worktree) => worktree.path === created.worktreePath)).toBe(false);
|
|
});
|
|
|
|
it("runs teardown commands from paseo.json before deleting a worktree", async () => {
|
|
const paseoConfig = {
|
|
worktree: {
|
|
teardown: [
|
|
'echo "source=$PASEO_SOURCE_CHECKOUT_PATH" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"',
|
|
'echo "root_alias=$PASEO_ROOT_PATH" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"',
|
|
'echo "worktree=$PASEO_WORKTREE_PATH" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"',
|
|
'echo "branch=$PASEO_BRANCH_NAME" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"',
|
|
'echo "port=$PASEO_WORKTREE_PORT" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"',
|
|
],
|
|
},
|
|
};
|
|
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
|
|
execSync(
|
|
'git add paseo.json && git -c commit.gpgsign=false commit -m "add teardown commands"',
|
|
{
|
|
cwd: repoDir,
|
|
},
|
|
);
|
|
|
|
const created = await createLegacyWorktreeForTest({
|
|
branchName: "teardown-branch",
|
|
cwd: repoDir,
|
|
baseBranch: "main",
|
|
worktreeSlug: "teardown-test",
|
|
paseoHome,
|
|
});
|
|
const runtimeEnv = await resolveWorktreeRuntimeEnv({
|
|
worktreePath: created.worktreePath,
|
|
branchName: created.branchName,
|
|
});
|
|
|
|
await deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome });
|
|
expect(existsSync(created.worktreePath)).toBe(false);
|
|
|
|
const teardownLog = readFileSync(join(repoDir, "teardown.log"), "utf8");
|
|
expect(teardownLog).toContain(`source=${repoDir}`);
|
|
expect(teardownLog).toContain(`root_alias=${repoDir}`);
|
|
expect(teardownLog).toContain(`worktree=${created.worktreePath}`);
|
|
expect(teardownLog).toContain("branch=teardown-branch");
|
|
expect(teardownLog).toContain(`port=${runtimeEnv.PASEO_WORKTREE_PORT}`);
|
|
});
|
|
|
|
it("runs string teardown scripts from paseo.json as a single shell command", async () => {
|
|
const paseoConfig = {
|
|
worktree: {
|
|
teardown:
|
|
'cleanup_message="teardown string"\necho "$cleanup_message" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"',
|
|
},
|
|
};
|
|
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
|
|
execSync('git add paseo.json && git -c commit.gpgsign=false commit -m "add string teardown"', {
|
|
cwd: repoDir,
|
|
});
|
|
|
|
const created = await createLegacyWorktreeForTest({
|
|
branchName: "teardown-string-branch",
|
|
cwd: repoDir,
|
|
baseBranch: "main",
|
|
worktreeSlug: "teardown-string-test",
|
|
paseoHome,
|
|
});
|
|
|
|
await deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome });
|
|
|
|
expect(getWorktreeTeardownCommands(repoDir)).toEqual([
|
|
'cleanup_message="teardown string"\necho "$cleanup_message" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"',
|
|
]);
|
|
expect(readFileSync(join(repoDir, "teardown.log"), "utf8").trim()).toBe("teardown string");
|
|
});
|
|
|
|
it("omits PASEO_WORKTREE_PORT from teardown env when runtime metadata is missing", async () => {
|
|
const paseoConfig = {
|
|
worktree: {
|
|
teardown: [
|
|
'echo "port=${PASEO_WORKTREE_PORT-unset}" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown-port.log"',
|
|
],
|
|
},
|
|
};
|
|
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
|
|
execSync(
|
|
'git add paseo.json && git -c commit.gpgsign=false commit -m "add teardown port logging"',
|
|
{ cwd: repoDir },
|
|
);
|
|
|
|
const created = await createLegacyWorktreeForTest({
|
|
branchName: "teardown-port-missing-branch",
|
|
cwd: repoDir,
|
|
baseBranch: "main",
|
|
worktreeSlug: "teardown-port-missing-test",
|
|
paseoHome,
|
|
});
|
|
|
|
await deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome });
|
|
|
|
expect(readFileSync(join(repoDir, "teardown-port.log"), "utf8").trim()).toBe("port=unset");
|
|
expect(existsSync(created.worktreePath)).toBe(false);
|
|
});
|
|
|
|
it("does not remove worktree when a teardown command fails", async () => {
|
|
const paseoConfig = {
|
|
worktree: {
|
|
teardown: [
|
|
'echo "started" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown-start.log"',
|
|
"echo boom 1>&2; exit 9",
|
|
],
|
|
},
|
|
};
|
|
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
|
|
execSync(
|
|
'git add paseo.json && git -c commit.gpgsign=false commit -m "add failing teardown commands"',
|
|
{ cwd: repoDir },
|
|
);
|
|
|
|
const created = await createLegacyWorktreeForTest({
|
|
branchName: "teardown-failure-branch",
|
|
cwd: repoDir,
|
|
baseBranch: "main",
|
|
worktreeSlug: "teardown-failure-test",
|
|
paseoHome,
|
|
});
|
|
|
|
await expect(
|
|
deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome }),
|
|
).rejects.toThrow("Worktree teardown command failed");
|
|
|
|
expect(existsSync(created.worktreePath)).toBe(true);
|
|
expect(existsSync(join(repoDir, "teardown-start.log"))).toBe(true);
|
|
});
|
|
|
|
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.allowed).toBe(true);
|
|
});
|
|
|
|
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("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("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("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);
|
|
}
|
|
});
|
|
});
|