* Add automatic plus branch maintenance workflow * Build plus artifacts for desktop and CLI/server * Document and summarize plus artifact workflow * Document Apple signing and pin plus workflow actions * Disable Linux plus desktop artifact * Publish plus CLI packages to GitHub Packages * Fix GitHub Packages publish paths * Fix plus mise install summary package name * Disable macOS x64 plus desktop artifact * Tag plus npm packages as latest for mise * fix(worktrees): respect selected local base ref * chore(ci): remove fork-only plus workflow * fix(worktrees): preserve exact branch picker refs --------- Co-authored-by: Matt Cowger <1929548+mcowger@users.noreply.github.com>
1339 lines
48 KiB
TypeScript
1339 lines
48 KiB
TypeScript
// POSIX-only: git worktree and teardown shell fixtures
|
|
/* eslint-disable max-nested-callbacks */
|
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
import {
|
|
BranchAlreadyCheckedOutError,
|
|
createWorktree as createWorktreePrimitive,
|
|
deriveWorktreeProjectHash,
|
|
deletePaseoWorktree,
|
|
InvalidGitBranchNameError,
|
|
getScriptConfigs,
|
|
getWorktreeSetupCommands,
|
|
getWorktreeTerminalSpecs,
|
|
getWorktreeTeardownCommands,
|
|
isServiceScript,
|
|
isPaseoOwnedWorktreeCwd,
|
|
listPaseoWorktrees,
|
|
readPaseoConfig,
|
|
resolveWorktreeRuntimeEnv,
|
|
type WorktreeSetupCommandProgressEvent,
|
|
runWorktreeSetupCommands,
|
|
type CreateWorktreeOptions,
|
|
type WorktreeConfig,
|
|
} from "./worktree";
|
|
import type { PaseoConfig } from "@getpaseo/protocol/paseo-config-schema";
|
|
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
|
|
import { execFileSync } from "child_process";
|
|
import { isPlatform } from "../test-utils/platform.js";
|
|
import {
|
|
mkdtempSync,
|
|
mkdirSync,
|
|
rmSync,
|
|
existsSync,
|
|
realpathSync,
|
|
writeFileSync,
|
|
readFileSync,
|
|
chmodSync,
|
|
} from "fs";
|
|
import { delimiter, dirname, join } from "path";
|
|
import { tmpdir } from "os";
|
|
import net from "node:net";
|
|
|
|
function loadConfigForTest(repoRoot: string): PaseoConfig | null {
|
|
const result = readPaseoConfig(repoRoot);
|
|
return result.ok ? result.config : null;
|
|
}
|
|
|
|
interface LegacyCreateWorktreeTestOptions {
|
|
branchName: string;
|
|
cwd: string;
|
|
baseBranch: string;
|
|
worktreeSlug: string;
|
|
runSetup?: boolean;
|
|
paseoHome?: string;
|
|
worktreesRoot?: 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,
|
|
worktreesRoot: options.worktreesRoot,
|
|
});
|
|
}
|
|
|
|
describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
|
describe("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 });
|
|
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(() => {
|
|
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("creates and owns worktrees under a configured root", async () => {
|
|
const worktreesRoot = join(tempDir, "custom-worktrees");
|
|
const projectHash = await deriveWorktreeProjectHash(repoDir);
|
|
const result = await createLegacyWorktreeForTest({
|
|
branchName: "custom-root",
|
|
cwd: repoDir,
|
|
baseBranch: "main",
|
|
worktreeSlug: "custom-root",
|
|
paseoHome,
|
|
worktreesRoot,
|
|
});
|
|
|
|
expect(result.worktreePath).toBe(join(worktreesRoot, projectHash, "custom-root"));
|
|
await expect(
|
|
isPaseoOwnedWorktreeCwd(result.worktreePath, { paseoHome, worktreesRoot }),
|
|
).resolves.toMatchObject({ allowed: true, worktreeRoot: join(worktreesRoot, projectHash) });
|
|
await expect(
|
|
isPaseoOwnedWorktreeCwd(result.worktreePath, { paseoHome }),
|
|
).resolves.toMatchObject({ allowed: false });
|
|
|
|
const worktrees = await listPaseoWorktrees({ cwd: repoDir, paseoHome, worktreesRoot });
|
|
expect(worktrees.map((entry) => entry.path)).toContain(result.worktreePath);
|
|
|
|
await deletePaseoWorktree({
|
|
cwd: repoDir,
|
|
worktreePath: result.worktreePath,
|
|
paseoHome,
|
|
worktreesBaseRoot: worktreesRoot,
|
|
});
|
|
expect(existsSync(result.worktreePath)).toBe(false);
|
|
});
|
|
|
|
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 });
|
|
execFileSync("git", ["init", "-b", "main"], { cwd: varRepoDir });
|
|
execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: varRepoDir });
|
|
execFileSync("git", ["config", "user.name", "Test"], { cwd: varRepoDir });
|
|
writeFileSync(join(varRepoDir, "file.txt"), "hello\n");
|
|
execFileSync("git", ["add", "."], { cwd: varRepoDir });
|
|
execFileSync("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 = execFileSync("git", ["branch", "--show-current"], {
|
|
cwd: result.worktreePath,
|
|
})
|
|
.toString()
|
|
.trim();
|
|
expect(currentBranch).toBe("feature/x");
|
|
execFileSync("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 () => {
|
|
execFileSync("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 = execFileSync("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("checks out an existing local branch whose name contains uppercase letters and dots", async () => {
|
|
execFileSync("git", ["branch", "release/1.1.15"], { cwd: repoDir });
|
|
|
|
const result = await createLegacyWorktreeForTest({
|
|
cwd: repoDir,
|
|
worktreeSlug: "release-worktree",
|
|
source: { kind: "checkout-branch", branchName: "release/1.1.15" },
|
|
runSetup: true,
|
|
paseoHome,
|
|
});
|
|
|
|
expect(existsSync(result.worktreePath)).toBe(true);
|
|
const currentBranch = execFileSync("git", ["branch", "--show-current"], {
|
|
cwd: result.worktreePath,
|
|
})
|
|
.toString()
|
|
.trim();
|
|
expect(currentBranch).toBe("release/1.1.15");
|
|
});
|
|
|
|
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");
|
|
execFileSync("git", ["clone", "--bare", repoDir, remoteDir]);
|
|
execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir });
|
|
|
|
execFileSync("git", ["clone", remoteDir, remoteCloneDir]);
|
|
execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: remoteCloneDir });
|
|
execFileSync("git", ["config", "user.name", "Test"], { cwd: remoteCloneDir });
|
|
execFileSync("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'] } }),
|
|
);
|
|
execFileSync("git", ["add", "."], { cwd: remoteCloneDir });
|
|
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "pr branch"], {
|
|
cwd: remoteCloneDir,
|
|
});
|
|
const prHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: remoteCloneDir })
|
|
.toString()
|
|
.trim();
|
|
execFileSync("git", ["push", "origin", "contributor/feature"], { cwd: remoteCloneDir });
|
|
execFileSync("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 = execFileSync("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("fetches a GitHub PR branch when the head ref contains uppercase letters and dots", async () => {
|
|
const remoteDir = join(tempDir, "remote.git");
|
|
const remoteCloneDir = join(tempDir, "remote-clone");
|
|
execFileSync("git", ["clone", "--bare", repoDir, remoteDir]);
|
|
execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir });
|
|
|
|
execFileSync("git", ["clone", remoteDir, remoteCloneDir]);
|
|
execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: remoteCloneDir });
|
|
execFileSync("git", ["config", "user.name", "Test"], { cwd: remoteCloneDir });
|
|
execFileSync("git", ["checkout", "-b", "Feature.X"], { cwd: remoteCloneDir });
|
|
writeFileSync(join(remoteCloneDir, "file.txt"), "from-uppercase-pr\n");
|
|
execFileSync("git", ["add", "file.txt"], { cwd: remoteCloneDir });
|
|
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "uppercase pr branch"], {
|
|
cwd: remoteCloneDir,
|
|
});
|
|
const prHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: remoteCloneDir })
|
|
.toString()
|
|
.trim();
|
|
execFileSync("git", ["push", "origin", "Feature.X"], { cwd: remoteCloneDir });
|
|
execFileSync("git", [`--git-dir=${remoteDir}`, "update-ref", "refs/pull/43/head", prHead]);
|
|
|
|
const result = await createLegacyWorktreeForTest({
|
|
cwd: repoDir,
|
|
worktreeSlug: "pr-43",
|
|
source: {
|
|
kind: "checkout-github-pr",
|
|
githubPrNumber: 43,
|
|
headRef: "Feature.X",
|
|
baseRefName: "main",
|
|
},
|
|
runSetup: true,
|
|
paseoHome,
|
|
});
|
|
|
|
expect(readFileSync(join(result.worktreePath, "file.txt"), "utf8")).toBe(
|
|
"from-uppercase-pr\n",
|
|
);
|
|
const currentBranch = execFileSync("git", ["branch", "--show-current"], {
|
|
cwd: result.worktreePath,
|
|
})
|
|
.toString()
|
|
.trim();
|
|
expect(currentBranch).toBe("Feature.X");
|
|
});
|
|
|
|
it("uses the selected local or origin ref when both exist", async () => {
|
|
const remoteDir = join(tempDir, "remote.git");
|
|
const remoteCloneDir = join(tempDir, "remote-clone");
|
|
execFileSync("git", ["init", "--bare", remoteDir]);
|
|
execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir });
|
|
execFileSync("git", ["push", "-u", "origin", "main"], { cwd: repoDir });
|
|
|
|
execFileSync("git", ["clone", remoteDir, remoteCloneDir]);
|
|
execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: remoteCloneDir });
|
|
execFileSync("git", ["config", "user.name", "Test"], { cwd: remoteCloneDir });
|
|
execFileSync("git", ["checkout", "-B", "main", "origin/main"], { cwd: remoteCloneDir });
|
|
writeFileSync(join(remoteCloneDir, "file.txt"), "from-origin\n");
|
|
execFileSync("git", ["add", "file.txt"], { cwd: remoteCloneDir });
|
|
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "advance origin main"], {
|
|
cwd: remoteCloneDir,
|
|
});
|
|
execFileSync("git", ["push", "origin", "main"], { cwd: remoteCloneDir });
|
|
|
|
writeFileSync(join(repoDir, "file.txt"), "from-local\n");
|
|
execFileSync("git", ["add", "file.txt"], { cwd: repoDir });
|
|
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "advance local main"], {
|
|
cwd: repoDir,
|
|
});
|
|
|
|
execFileSync("git", ["fetch", "origin"], { cwd: repoDir });
|
|
|
|
const localResult = await createLegacyWorktreeForTest({
|
|
branchName: "prefer-local-feature",
|
|
cwd: repoDir,
|
|
baseBranch: "main",
|
|
worktreeSlug: "prefer-local-feature",
|
|
runSetup: false,
|
|
paseoHome,
|
|
});
|
|
const originResult = await createLegacyWorktreeForTest({
|
|
branchName: "prefer-origin-feature",
|
|
cwd: repoDir,
|
|
baseBranch: "refs/remotes/origin/main",
|
|
worktreeSlug: "prefer-origin-feature",
|
|
runSetup: false,
|
|
paseoHome,
|
|
});
|
|
|
|
expect(readFileSync(join(localResult.worktreePath, "file.txt"), "utf8")).toBe("from-local\n");
|
|
expect(readFileSync(join(originResult.worktreePath, "file.txt"), "utf8")).toBe(
|
|
"from-origin\n",
|
|
);
|
|
expect(
|
|
JSON.parse(readFileSync(getPaseoWorktreeMetadataPath(originResult.worktreePath), "utf8")),
|
|
).toMatchObject({ baseRefName: "main" });
|
|
});
|
|
|
|
it("uses a local branch when origin/{branch} does not exist", async () => {
|
|
writeFileSync(join(repoDir, "file.txt"), "from-local-only\n");
|
|
execFileSync("git", ["add", "file.txt"], { cwd: repoDir });
|
|
execFileSync(
|
|
"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("throws a typed error when checking out an invalid existing branch name", async () => {
|
|
let caughtError: unknown;
|
|
try {
|
|
await createLegacyWorktreeForTest({
|
|
cwd: repoDir,
|
|
worktreeSlug: "invalid-existing-branch",
|
|
source: { kind: "checkout-branch", branchName: "bad..name" },
|
|
runSetup: true,
|
|
paseoHome,
|
|
});
|
|
} catch (error) {
|
|
caughtError = error;
|
|
}
|
|
|
|
expect(caughtError).toBeInstanceOf(InvalidGitBranchNameError);
|
|
expect((caughtError as InvalidGitBranchNameError).branchName).toBe("bad..name");
|
|
});
|
|
|
|
it("throws a typed error when checking out a ref that is valid but not a branch name", async () => {
|
|
let caughtError: unknown;
|
|
try {
|
|
await createLegacyWorktreeForTest({
|
|
cwd: repoDir,
|
|
worktreeSlug: "invalid-option-like-branch",
|
|
source: { kind: "checkout-branch", branchName: "-bad" },
|
|
runSetup: true,
|
|
paseoHome,
|
|
});
|
|
} catch (error) {
|
|
caughtError = error;
|
|
}
|
|
|
|
expect(caughtError).toBeInstanceOf(InvalidGitBranchNameError);
|
|
expect((caughtError as InvalidGitBranchNameError).branchName).toBe("-bad");
|
|
});
|
|
|
|
it("handles branch name collision by adding suffix", async () => {
|
|
const projectHash = await deriveWorktreeProjectHash(repoDir);
|
|
// Create a branch named "hello" first
|
|
execFileSync("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 = execFileSync("git", ["branch"], { cwd: repoDir }).toString();
|
|
expect(branches).toContain("hello-1");
|
|
});
|
|
|
|
it("handles multiple collisions", async () => {
|
|
// Create branches "hello" and "hello-1"
|
|
execFileSync("git", ["branch", "hello"], { cwd: repoDir });
|
|
execFileSync("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 = execFileSync("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));
|
|
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir });
|
|
execFileSync("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));
|
|
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir });
|
|
execFileSync("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("runs setup commands with the daemon PATH instead of login profile PATH", async () => {
|
|
const home = join(tempDir, "host-home");
|
|
const binDir = join(tempDir, "daemon-bin");
|
|
mkdirSync(home);
|
|
mkdirSync(binDir);
|
|
|
|
const shimPath = join(binDir, "paseo-shim");
|
|
writeFileSync(shimPath, "#!/bin/sh\nprintf 'shim:%s\\n' \"$1\"\n");
|
|
chmodSync(shimPath, 0o755);
|
|
writeFileSync(join(home, ".bash_profile"), "export PATH=/usr/bin:/bin\n");
|
|
const bashEnvPath = join(home, "bash-env");
|
|
writeFileSync(bashEnvPath, "export PATH=/usr/bin:/bin\n");
|
|
writeFileSync(
|
|
join(repoDir, "paseo.json"),
|
|
JSON.stringify({
|
|
worktree: {
|
|
setup: "command -v paseo-shim >/dev/null && paseo-shim ok > setup-path.log",
|
|
},
|
|
}),
|
|
);
|
|
|
|
const originalHome = process.env.HOME;
|
|
const originalPath = process.env.PATH;
|
|
const originalBashEnv = process.env.BASH_ENV;
|
|
process.env.HOME = home;
|
|
process.env.PATH = `${binDir}${delimiter}${originalPath ?? "/usr/bin:/bin"}`;
|
|
process.env.BASH_ENV = bashEnvPath;
|
|
|
|
try {
|
|
await runWorktreeSetupCommands({
|
|
worktreePath: repoDir,
|
|
branchName: "main",
|
|
cleanupOnFailure: false,
|
|
runtimeEnv: {
|
|
PASEO_SOURCE_CHECKOUT_PATH: repoDir,
|
|
PASEO_ROOT_PATH: repoDir,
|
|
PASEO_WORKTREE_PATH: repoDir,
|
|
PASEO_BRANCH_NAME: "main",
|
|
PASEO_WORKTREE_PORT: "12345",
|
|
},
|
|
});
|
|
} finally {
|
|
if (originalHome === undefined) {
|
|
delete process.env.HOME;
|
|
} else {
|
|
process.env.HOME = originalHome;
|
|
}
|
|
if (originalPath === undefined) {
|
|
delete process.env.PATH;
|
|
} else {
|
|
process.env.PATH = originalPath;
|
|
}
|
|
if (originalBashEnv === undefined) {
|
|
delete process.env.BASH_ENV;
|
|
} else {
|
|
process.env.BASH_ENV = originalBashEnv;
|
|
}
|
|
}
|
|
|
|
expect(readFileSync(join(repoDir, "setup-path.log"), "utf8").trim()).toBe("shim:ok");
|
|
});
|
|
|
|
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));
|
|
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir });
|
|
execFileSync("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));
|
|
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir });
|
|
execFileSync("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));
|
|
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir });
|
|
execFileSync("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" } } }),
|
|
);
|
|
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir });
|
|
execFileSync("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 });
|
|
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(() => {
|
|
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 });
|
|
execFileSync("git", ["init", "-b", "main"], { cwd: repo });
|
|
execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: repo });
|
|
execFileSync("git", ["config", "user.name", "Test"], { cwd: repo });
|
|
writeFileSync(join(repo, "file.txt"), "hello\n");
|
|
execFileSync("git", ["add", "."], { cwd: repo });
|
|
execFileSync("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));
|
|
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir });
|
|
execFileSync("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));
|
|
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir });
|
|
execFileSync("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));
|
|
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir });
|
|
execFileSync(
|
|
"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));
|
|
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir });
|
|
execFileSync(
|
|
"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);
|
|
});
|
|
});
|
|
});
|