mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-26 11:12:53 +00:00
* fix(server): start new worktrees at the remote tip of an origin/* base Branching off a remote-tracking base such as origin/main used whatever the repository had cached. Nothing on the branch-off path fetched, and the background fetch only runs while a workspace of that repository is observed, so a new workspace could start hours or weeks behind the remote. Fetch just the base branch from its remote before git worktree add. The refspec is passed on the command line so remote.<name>.fetch is untouched, and a failed fetch falls back to the cached ref so creation still works offline. Co-authored-by: odoo-mvds <mv@droggol.be> * fix(server): fall back to the cached base before the create request times out A remote that accepts the connection and never answers held the base-ref fetch for its 120s cap, past the 60s client request timeout, so paseo worktree create failed and the worktree appeared later. Cap the fetch at 15s so creation falls back to the cached ref in time. --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: odoo-mvds <mv@droggol.be>
465 lines
16 KiB
TypeScript
465 lines
16 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
import {
|
|
createWorktree as createWorktreePrimitive,
|
|
deriveWorktreeProjectHash,
|
|
deletePaseoWorktree,
|
|
isPaseoOwnedWorktreeCwd,
|
|
mapWorkspaceCwdToWorktree,
|
|
slugify,
|
|
type CreateWorktreeOptions,
|
|
type WorktreeConfig,
|
|
} from "./worktree";
|
|
import { execFileSync } from "child_process";
|
|
import {
|
|
mkdtempSync,
|
|
mkdirSync,
|
|
rmSync,
|
|
existsSync,
|
|
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;
|
|
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,
|
|
});
|
|
}
|
|
|
|
// 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(() => {
|
|
rmSync(tempDir, { recursive: true, force: 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);
|
|
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("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);
|
|
}
|
|
});
|
|
});
|