mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 21:32:40 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
668204cd61 | ||
|
|
dfe2f6ade0 |
@@ -50,6 +50,12 @@ The daemon also supports an optional shared-secret password (set via `auth.passw
|
||||
|
||||
Connected clients are trusted operators of the daemon user. File previews follow that authority: a preview request may read any regular file the daemon process can read, while keeping path normalization and symlink checks in the daemon file service. Workspace-relative paths remain a UI convenience, not a security boundary.
|
||||
|
||||
An explicit `symlink <path>` entry in a repository's .worktreeinclude intentionally gives a
|
||||
Paseo-created worktree live access to that source-checkout file or directory. It is useful for
|
||||
local dependencies and caches, but it weakens the usual worktree isolation: agents and lifecycle
|
||||
scripts can modify the source through the link. Paseo validates entries and refuses traversal or
|
||||
destination-link escapes, but the linked source is a deliberate shared-data boundary.
|
||||
|
||||
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. Setting a password is strongly recommended in that case.
|
||||
|
||||
In Docker, the official image runs the daemon and agents as the non-root
|
||||
|
||||
@@ -29,6 +29,7 @@ Root checkout dev is intentionally split across terminals:
|
||||
- **Repo dev scripts** default to `$ROOT/.dev/paseo-home`, where `$ROOT` is the current checkout or worktree root. This keeps all dev state scoped to the checkout instead of the packaged desktop app.
|
||||
- **`npm run cli -- ...`** runs through the same dev-home wrapper as the dev scripts, so the in-repo CLI automatically targets the current checkout's `.dev/paseo-home` and configured dev daemon endpoint.
|
||||
- **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying durable JSON metadata. Runtime files like pid files, sockets, and logs are not copied.
|
||||
- **Paseo-created worktrees** read `.worktreeinclude` from the live source checkout before creation. Bare paths and `copy <path>` copy a snapshot into the new worktree; `symlink <path>` creates a live source link. Missing paths, malformed entries, unsafe paths, incompatible include overlaps, destination conflicts, unavailable platform links, and ordinary read/write failures are skipped individually and reported in the daemon log, so the rest of the plan still runs. A source symlink is allowed only when its resolved target remains inside the active source checkout. If that checkout is itself Paseo-managed, its own paths remain eligible while other managed worktree paths stay protected; `copy` snapshots that resolved target, while `symlink` links directly to it. Hard links are ordinary files. Each include is staged before it is committed; Paseo aborts creation only if it cannot safely clean up partial materialization state (or Git/worktree setup itself fails). Materialization finishes before `worktree.setup` runs.
|
||||
- **This repo's worktree setup** also best-effort seeds `packages/app/ios` and the newest `.dev/ios-build` entry from the source checkout so iOS simulator services can reuse native project and Xcode cache state when it is safe enough to do so.
|
||||
|
||||
Override knobs:
|
||||
|
||||
@@ -315,6 +315,7 @@ configureLinuxSandbox({
|
||||
resourcesPath: process.resourcesPath,
|
||||
statSandbox: (sandboxPath) => statSync(sandboxPath),
|
||||
disableSandbox: () => app.commandLine.appendSwitch("no-sandbox"),
|
||||
reportSandboxDisabled: (report) => log.warn("[linux-sandbox] disabling Chromium sandbox", report),
|
||||
reportInspectionError: (error) => log.error("[linux-sandbox] failed to inspect helper", error),
|
||||
});
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ function configureWithSandbox(
|
||||
) {
|
||||
const disabledSwitches: string[] = [];
|
||||
const inspectionErrors: unknown[] = [];
|
||||
const disableReports: unknown[] = [];
|
||||
|
||||
configureLinuxSandbox({
|
||||
platform,
|
||||
@@ -23,10 +24,11 @@ function configureWithSandbox(
|
||||
return sandbox;
|
||||
},
|
||||
disableSandbox: () => disabledSwitches.push("no-sandbox"),
|
||||
reportSandboxDisabled: (report) => disableReports.push(report),
|
||||
reportInspectionError: (error) => inspectionErrors.push(error),
|
||||
});
|
||||
|
||||
return { disabledSwitches, inspectionErrors };
|
||||
return { disabledSwitches, disableReports, inspectionErrors };
|
||||
}
|
||||
|
||||
function createFileSystemError(code: string): NodeJS.ErrnoException {
|
||||
@@ -39,6 +41,14 @@ describe("configureLinuxSandbox", () => {
|
||||
it("disables the sandbox when an AppImage mount strips SUID", () => {
|
||||
expect(configureWithSandbox({ uid: 1000, mode: 0o755 })).toEqual({
|
||||
disabledSwitches: ["no-sandbox"],
|
||||
disableReports: [
|
||||
{
|
||||
reason: "wrong-owner-or-mode",
|
||||
sandboxPath: "/opt/Paseo/chrome-sandbox",
|
||||
observedUid: 1000,
|
||||
observedMode: "0755",
|
||||
},
|
||||
],
|
||||
inspectionErrors: [],
|
||||
});
|
||||
});
|
||||
@@ -46,6 +56,7 @@ describe("configureLinuxSandbox", () => {
|
||||
it("keeps the sandbox for a root-owned 4755 helper", () => {
|
||||
expect(configureWithSandbox({ uid: 0, mode: 0o4755 })).toEqual({
|
||||
disabledSwitches: [],
|
||||
disableReports: [],
|
||||
inspectionErrors: [],
|
||||
});
|
||||
});
|
||||
@@ -53,6 +64,14 @@ describe("configureLinuxSandbox", () => {
|
||||
it("disables the sandbox when a SUID helper is not root-owned", () => {
|
||||
expect(configureWithSandbox({ uid: 1000, mode: 0o4755 })).toEqual({
|
||||
disabledSwitches: ["no-sandbox"],
|
||||
disableReports: [
|
||||
{
|
||||
reason: "wrong-owner-or-mode",
|
||||
sandboxPath: "/opt/Paseo/chrome-sandbox",
|
||||
observedUid: 1000,
|
||||
observedMode: "4755",
|
||||
},
|
||||
],
|
||||
inspectionErrors: [],
|
||||
});
|
||||
});
|
||||
@@ -60,6 +79,12 @@ describe("configureLinuxSandbox", () => {
|
||||
it("disables the sandbox when the helper is missing", () => {
|
||||
expect(configureWithSandbox(createFileSystemError("ENOENT"))).toEqual({
|
||||
disabledSwitches: ["no-sandbox"],
|
||||
disableReports: [
|
||||
{
|
||||
reason: "helper-missing",
|
||||
sandboxPath: "/opt/Paseo/chrome-sandbox",
|
||||
},
|
||||
],
|
||||
inspectionErrors: [],
|
||||
});
|
||||
});
|
||||
@@ -69,6 +94,7 @@ describe("configureLinuxSandbox", () => {
|
||||
|
||||
expect(configureWithSandbox(permissionError)).toEqual({
|
||||
disabledSwitches: [],
|
||||
disableReports: [],
|
||||
inspectionErrors: [permissionError],
|
||||
});
|
||||
});
|
||||
@@ -76,6 +102,7 @@ describe("configureLinuxSandbox", () => {
|
||||
it("does not inspect or configure the sandbox outside Linux", () => {
|
||||
expect(configureWithSandbox(createFileSystemError("EACCES"), "darwin")).toEqual({
|
||||
disabledSwitches: [],
|
||||
disableReports: [],
|
||||
inspectionErrors: [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,11 +8,24 @@ interface SandboxMetadata {
|
||||
uid: number;
|
||||
}
|
||||
|
||||
type SandboxDisableReport =
|
||||
| {
|
||||
reason: "helper-missing";
|
||||
sandboxPath: string;
|
||||
}
|
||||
| {
|
||||
reason: "wrong-owner-or-mode";
|
||||
sandboxPath: string;
|
||||
observedUid: number;
|
||||
observedMode: string;
|
||||
};
|
||||
|
||||
interface LinuxSandboxConfiguration {
|
||||
platform: NodeJS.Platform;
|
||||
resourcesPath: string;
|
||||
statSandbox: (sandboxPath: string) => SandboxMetadata;
|
||||
disableSandbox: () => void;
|
||||
reportSandboxDisabled: (report: SandboxDisableReport) => void;
|
||||
reportInspectionError: (error: unknown) => void;
|
||||
}
|
||||
|
||||
@@ -25,17 +38,25 @@ export function configureLinuxSandbox(input: LinuxSandboxConfiguration): void {
|
||||
return;
|
||||
}
|
||||
|
||||
const sandboxPath = path.join(input.resourcesPath, "..", "chrome-sandbox");
|
||||
|
||||
try {
|
||||
const sandboxPath = path.join(input.resourcesPath, "..", "chrome-sandbox");
|
||||
const sandbox = input.statSandbox(sandboxPath);
|
||||
const hasUsableSandbox =
|
||||
sandbox.uid === 0 && (sandbox.mode & PERMISSION_BITS) === REQUIRED_SANDBOX_MODE;
|
||||
|
||||
if (!hasUsableSandbox) {
|
||||
input.reportSandboxDisabled({
|
||||
reason: "wrong-owner-or-mode",
|
||||
sandboxPath,
|
||||
observedUid: sandbox.uid,
|
||||
observedMode: (sandbox.mode & PERMISSION_BITS).toString(8).padStart(4, "0"),
|
||||
});
|
||||
input.disableSandbox();
|
||||
}
|
||||
} catch (error) {
|
||||
if (isMissingSandbox(error)) {
|
||||
input.reportSandboxDisabled({ reason: "helper-missing", sandboxPath });
|
||||
input.disableSandbox();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import type { WorktreeCreationIntent } from "./resolve-worktree-creation-intent.
|
||||
import { resolveFirstAgentPromptTitle } from "./agent/create-agent-title.js";
|
||||
import { buildAgentBranchNameSeed } from "./agent/prompt-attachments.js";
|
||||
import type { FirstAgentContext } from "@getpaseo/protocol/messages";
|
||||
import type { WorktreeIncludeSummary } from "../utils/worktree-include.js";
|
||||
|
||||
export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {
|
||||
projectId?: string;
|
||||
@@ -36,6 +37,7 @@ export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {
|
||||
|
||||
export interface CreatePaseoWorktreeResult {
|
||||
worktree: WorktreeConfig;
|
||||
worktreeIncludeSummary?: WorktreeIncludeSummary;
|
||||
intent: WorktreeCreationIntent;
|
||||
workspace: PersistedWorkspaceRecord;
|
||||
repoRoot: string;
|
||||
@@ -98,6 +100,7 @@ export async function createPaseoWorktree(
|
||||
|
||||
return {
|
||||
worktree: createdWorktree.worktree,
|
||||
worktreeIncludeSummary: createdWorktree.worktreeIncludeSummary,
|
||||
intent: createdWorktree.intent,
|
||||
workspace,
|
||||
repoRoot: createdWorktree.repoRoot,
|
||||
|
||||
@@ -746,6 +746,7 @@ export class Session {
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
this.workspaceRecovery = createWorkspaceRecoveryService({
|
||||
logger: this.sessionLogger,
|
||||
paseoHome: this.paseoHome,
|
||||
worktreesRoot: this.worktreesRoot,
|
||||
getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId),
|
||||
|
||||
+14
@@ -76,6 +76,7 @@ function createHarness(input?: {
|
||||
const directories = new Set(input?.directories ?? ["/repo"]);
|
||||
const unarchived: string[] = [];
|
||||
const service = createWorkspaceRecoveryService({
|
||||
logger: { warn: () => undefined } as never,
|
||||
paseoHome: input?.paseoHome ?? "/paseo-home",
|
||||
worktreesRoot: input?.worktreesRoot ?? "/worktrees",
|
||||
getWorkspace: async (workspaceId) =>
|
||||
@@ -135,6 +136,7 @@ describe("workspace recovery", () => {
|
||||
const sourceSubdirectory = join(repoDir, "packages", "app");
|
||||
mkdirSync(sourceSubdirectory, { recursive: true });
|
||||
writeFileSync(join(sourceSubdirectory, "README.md"), "app\n");
|
||||
writeFileSync(join(repoDir, ".worktreeinclude"), "missing.local\n");
|
||||
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "add app"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" });
|
||||
@@ -170,7 +172,9 @@ describe("workspace recovery", () => {
|
||||
mainRepoRoot: repoDir,
|
||||
});
|
||||
const unarchived: string[] = [];
|
||||
const warnings: unknown[][] = [];
|
||||
const service = createWorkspaceRecoveryService({
|
||||
logger: { warn: (...args: unknown[]) => warnings.push(args) } as never,
|
||||
paseoHome,
|
||||
worktreesRoot,
|
||||
getWorkspace: async (workspaceId) =>
|
||||
@@ -189,6 +193,15 @@ describe("workspace recovery", () => {
|
||||
expect(existsSync(worktreeRoot)).toBe(true);
|
||||
expect(existsSync(workspaceCwd)).toBe(true);
|
||||
expect(unarchived).toEqual([workspace.workspaceId]);
|
||||
expect(warnings).toEqual([
|
||||
[
|
||||
expect.objectContaining({
|
||||
materialized: 0,
|
||||
skipped: [expect.objectContaining({ raw: "missing.local", reason: "missing" })],
|
||||
}),
|
||||
"Worktree include completed with skipped entries during workspace recovery",
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps an exact-subdirectory workspace archived when its branch lacks that directory", async () => {
|
||||
@@ -220,6 +233,7 @@ describe("workspace recovery", () => {
|
||||
});
|
||||
const unarchived: string[] = [];
|
||||
const service = createWorkspaceRecoveryService({
|
||||
logger: { warn: () => undefined } as never,
|
||||
paseoHome,
|
||||
worktreesRoot,
|
||||
getWorkspace: async (workspaceId) =>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { basename } from "node:path";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import { createRealpathAwarePathMatcher } from "../../../utils/path.js";
|
||||
import { runGitCommand } from "../../../utils/run-git-command.js";
|
||||
@@ -59,6 +60,7 @@ type RecoveryPlan =
|
||||
type UnavailableRecoveryState = Extract<WorkspaceRecoveryState, { kind: "unavailable" }>;
|
||||
|
||||
export function createWorkspaceRecoveryService(deps: {
|
||||
logger: Logger;
|
||||
paseoHome: string;
|
||||
worktreesRoot?: string;
|
||||
getWorkspace: (workspaceId: string) => Promise<PersistedWorkspaceRecord | null>;
|
||||
@@ -195,6 +197,16 @@ export function createWorkspaceRecoveryService(deps: {
|
||||
worktreesRoot: deps.worktreesRoot,
|
||||
});
|
||||
recreatedWorktreePath = result.worktreePath;
|
||||
if (result.worktreeIncludeSummary.skipped.length > 0) {
|
||||
deps.logger.warn(
|
||||
{
|
||||
materialized: result.worktreeIncludeSummary.materialized,
|
||||
skipped: result.worktreeIncludeSummary.skipped,
|
||||
worktreePath: result.worktreePath,
|
||||
},
|
||||
"Worktree include completed with skipped entries during workspace recovery",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
throw toWorktreeRequestError(error);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
validateBranchSlug,
|
||||
type WorktreeConfig,
|
||||
} from "../utils/worktree.js";
|
||||
import type { WorktreeIncludeSummary } from "../utils/worktree-include.js";
|
||||
import {
|
||||
resolveWorktreeCreationIntent,
|
||||
type ResolveWorktreeCreationIntentInput,
|
||||
@@ -42,6 +43,7 @@ export interface CreateWorktreeCoreDeps {
|
||||
|
||||
export interface CreateWorktreeCoreResult {
|
||||
worktree: WorktreeConfig;
|
||||
worktreeIncludeSummary?: WorktreeIncludeSummary;
|
||||
intent: WorktreeCreationIntent;
|
||||
repoRoot: string;
|
||||
created: boolean;
|
||||
@@ -120,15 +122,17 @@ export async function createWorktreeCore(
|
||||
return { worktree: existingWorktree, intent, repoRoot, created: false };
|
||||
}
|
||||
|
||||
const { worktreeIncludeSummary, ...worktree } = await createWorktree({
|
||||
cwd: repoRoot,
|
||||
worktreeSlug: normalizedSlug,
|
||||
source: intent,
|
||||
runSetup: input.runSetup ?? true,
|
||||
paseoHome: input.paseoHome,
|
||||
worktreesRoot: input.worktreesRoot,
|
||||
});
|
||||
return {
|
||||
worktree: await createWorktree({
|
||||
cwd: repoRoot,
|
||||
worktreeSlug: normalizedSlug,
|
||||
source: intent,
|
||||
runSetup: input.runSetup ?? true,
|
||||
paseoHome: input.paseoHome,
|
||||
worktreesRoot: input.worktreesRoot,
|
||||
}),
|
||||
worktree,
|
||||
worktreeIncludeSummary,
|
||||
intent,
|
||||
repoRoot,
|
||||
created: true,
|
||||
|
||||
@@ -605,6 +605,17 @@ export async function createPaseoWorktreeWorkflow(
|
||||
const workspace = createdWorktree.workspace;
|
||||
const setupContinuation = options?.setupContinuation ?? { kind: "workspace" };
|
||||
|
||||
if (createdWorktree.created && createdWorktree.worktreeIncludeSummary?.skipped.length) {
|
||||
dependencies.sessionLogger.warn(
|
||||
{
|
||||
materialized: createdWorktree.worktreeIncludeSummary.materialized,
|
||||
skipped: createdWorktree.worktreeIncludeSummary.skipped,
|
||||
worktreePath: createdWorktree.worktree.worktreePath,
|
||||
},
|
||||
"Worktree include completed with skipped entries",
|
||||
);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (input.firstAgentContext) {
|
||||
dependencies.autoNameWorkspaceBranchForFirstAgent({
|
||||
|
||||
@@ -0,0 +1,635 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
existsSync,
|
||||
chmodSync,
|
||||
lstatSync,
|
||||
linkSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { dirname, join, relative } from "path";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
import { materializeWorktreeIncludePlan, readWorktreeIncludePlan } from "./worktree-include.js";
|
||||
|
||||
describe("worktree include planning", () => {
|
||||
let tempDir: string;
|
||||
let sourceRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "worktree-include-test-"));
|
||||
sourceRoot = join(tempDir, "source");
|
||||
mkdirSync(sourceRoot);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("copies bare entries and accepts explicit copy and symlink modes", async () => {
|
||||
writeFileSync(
|
||||
join(sourceRoot, ".worktreeinclude"),
|
||||
[".env.local", ".cache/**", "symlink shared-state", "copy packages/*/.runtime.env", ""].join(
|
||||
"\n",
|
||||
),
|
||||
);
|
||||
writeFileSync(join(sourceRoot, ".env.local"), "source\n");
|
||||
mkdirSync(join(sourceRoot, ".cache"), { recursive: true });
|
||||
writeFileSync(join(sourceRoot, ".cache", "state.txt"), "cache\n");
|
||||
mkdirSync(join(sourceRoot, "shared-state"), { recursive: true });
|
||||
writeFileSync(join(sourceRoot, "shared-state", "state.txt"), "shared\n");
|
||||
mkdirSync(join(sourceRoot, "packages", "api"), { recursive: true });
|
||||
writeFileSync(join(sourceRoot, "packages", "api", ".runtime.env"), "api\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
|
||||
expect(plan.materializations).toHaveLength(4);
|
||||
expect(plan.materializations).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ mode: "copy", relativePath: ".env.local", sourceKind: "file" }),
|
||||
expect.objectContaining({ mode: "copy", relativePath: ".cache", sourceKind: "directory" }),
|
||||
expect.objectContaining({
|
||||
mode: "symlink",
|
||||
relativePath: "shared-state",
|
||||
sourceKind: "directory",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
mode: "copy",
|
||||
relativePath: "packages/api/.runtime.env",
|
||||
sourceKind: "file",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips invalid and conflicting entries while retaining safe entries", async () => {
|
||||
writeFileSync(join(sourceRoot, "shared"), "source\n");
|
||||
writeFileSync(join(sourceRoot, ".env"), "source\n");
|
||||
writeFileSync(
|
||||
join(sourceRoot, ".worktreeinclude"),
|
||||
["../outside", "symlink", "shared", "symlink shared", ".env", ""].join("\n"),
|
||||
);
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
|
||||
expect(plan.materializations).toEqual([
|
||||
expect.objectContaining({ mode: "copy", relativePath: ".env", sourceKind: "file" }),
|
||||
]);
|
||||
expect(plan.skipped).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ lineNumber: 1, raw: "../outside", reason: "invalid" }),
|
||||
expect.objectContaining({ lineNumber: 2, raw: "symlink", reason: "invalid" }),
|
||||
expect.objectContaining({ lineNumber: 3, raw: "shared", reason: "conflict" }),
|
||||
expect.objectContaining({ lineNumber: 4, raw: "symlink shared", reason: "conflict" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips missing entries while retaining existing literal and glob matches", async () => {
|
||||
writeFileSync(
|
||||
join(sourceRoot, ".worktreeinclude"),
|
||||
[".env", ".env.local", "config/*.env", "missing/**", ""].join("\n"),
|
||||
);
|
||||
writeFileSync(join(sourceRoot, ".env"), "source\n");
|
||||
mkdirSync(join(sourceRoot, "config"), { recursive: true });
|
||||
writeFileSync(join(sourceRoot, "config", "runtime.env"), "runtime\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
|
||||
expect(plan.materializations).toEqual([
|
||||
expect.objectContaining({ mode: "copy", relativePath: ".env", sourceKind: "file" }),
|
||||
expect.objectContaining({
|
||||
mode: "copy",
|
||||
relativePath: "config/runtime.env",
|
||||
sourceKind: "file",
|
||||
}),
|
||||
]);
|
||||
expect(plan.skipped).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ raw: ".env.local", reason: "missing" }),
|
||||
expect.objectContaining({ raw: "missing/**", reason: "missing" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires the optimized trailing recursive form to resolve to a directory", async () => {
|
||||
writeFileSync(join(sourceRoot, "cache"), "not-a-directory\n");
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "cache/**\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
|
||||
expect(plan.materializations).toEqual([]);
|
||||
expect(plan.skipped).toEqual([expect.objectContaining({ raw: "cache/**", reason: "unsafe" })]);
|
||||
});
|
||||
|
||||
it("skips directory copies that overlap protected worktree paths", async () => {
|
||||
const protectedWorktreeRoot = join(sourceRoot, ".dev", "paseo-home", "worktrees", "project");
|
||||
mkdirSync(protectedWorktreeRoot, { recursive: true });
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), ".dev/**\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({
|
||||
sourceRoot,
|
||||
excludedSourceRoots: [protectedWorktreeRoot],
|
||||
});
|
||||
|
||||
expect(plan.materializations).toEqual([]);
|
||||
expect(plan.skipped).toEqual([expect.objectContaining({ raw: ".dev/**", reason: "unsafe" })]);
|
||||
});
|
||||
|
||||
it("protects every managed project beneath checkout-local worktree storage", async () => {
|
||||
const managedWorktreesRoot = join(sourceRoot, ".dev", "paseo-home", "worktrees");
|
||||
const siblingWorktree = join(managedWorktreesRoot, "other-project", "sibling");
|
||||
mkdirSync(siblingWorktree, { recursive: true });
|
||||
writeFileSync(join(siblingWorktree, ".env"), "secret\n");
|
||||
writeFileSync(
|
||||
join(sourceRoot, ".worktreeinclude"),
|
||||
".dev/paseo-home/worktrees/other-project/sibling/.env\n",
|
||||
);
|
||||
|
||||
const plan = await readWorktreeIncludePlan({
|
||||
sourceRoot,
|
||||
excludedSourceRoots: [managedWorktreesRoot],
|
||||
});
|
||||
|
||||
expect(plan.materializations).toEqual([]);
|
||||
expect(plan.skipped).toEqual([expect.objectContaining({ reason: "unsafe" })]);
|
||||
});
|
||||
|
||||
it("allows includes from a source worktree inside the protected worktree root", async () => {
|
||||
const managedWorktreesRoot = join(tempDir, "paseo-home", "worktrees", "project");
|
||||
sourceRoot = join(managedWorktreesRoot, "source-worktree");
|
||||
mkdirSync(sourceRoot, { recursive: true });
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), ".env\n");
|
||||
writeFileSync(join(sourceRoot, ".env"), "source\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({
|
||||
sourceRoot,
|
||||
excludedSourceRoots: [managedWorktreesRoot],
|
||||
});
|
||||
|
||||
expect(plan.materializations).toEqual([
|
||||
expect.objectContaining({ relativePath: ".env", sourceKind: "file" }),
|
||||
]);
|
||||
expect(plan.skipped).toEqual([]);
|
||||
});
|
||||
|
||||
it.skipIf(isPlatform("win32"))(
|
||||
"skips protected paths reached through a symlink alias",
|
||||
async () => {
|
||||
const sourceAlias = join(tempDir, "source-alias");
|
||||
symlinkSync(sourceRoot, sourceAlias, "dir");
|
||||
mkdirSync(join(sourceRoot, ".dev", "paseo-home", "worktrees", "project"), {
|
||||
recursive: true,
|
||||
});
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), ".dev/**\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({
|
||||
sourceRoot,
|
||||
excludedSourceRoots: [join(sourceAlias, ".dev", "paseo-home", "worktrees", "project")],
|
||||
});
|
||||
|
||||
expect(plan.materializations).toEqual([]);
|
||||
expect(plan.skipped).toEqual([expect.objectContaining({ raw: ".dev/**", reason: "unsafe" })]);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(isPlatform("win32"))(
|
||||
"skips external source links while retaining safe entries",
|
||||
async () => {
|
||||
const outsidePath = join(tempDir, "outside.txt");
|
||||
writeFileSync(outsidePath, "outside\n");
|
||||
writeFileSync(join(sourceRoot, "safe.txt"), "safe\n");
|
||||
symlinkSync(outsidePath, join(sourceRoot, "linked.txt"));
|
||||
writeFileSync(
|
||||
join(sourceRoot, ".worktreeinclude"),
|
||||
["safe.txt", "linked.txt", ""].join("\n"),
|
||||
);
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
|
||||
expect(plan.materializations).toEqual([
|
||||
expect.objectContaining({ relativePath: "safe.txt", sourceKind: "file" }),
|
||||
]);
|
||||
expect(plan.skipped).toEqual([
|
||||
expect.objectContaining({ raw: "linked.txt", reason: "unsafe" }),
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(isPlatform("win32"))("matches globs beneath a safe symlinked directory", async () => {
|
||||
mkdirSync(join(sourceRoot, "actual-config"));
|
||||
writeFileSync(join(sourceRoot, "actual-config", "runtime.env"), "runtime\n");
|
||||
symlinkSync(join(sourceRoot, "actual-config"), join(sourceRoot, "config"), "dir");
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "config/*.env\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
|
||||
expect(plan.materializations).toEqual([
|
||||
expect.objectContaining({ relativePath: "config/runtime.env", sourceKind: "file" }),
|
||||
]);
|
||||
expect(plan.skipped).toEqual([]);
|
||||
});
|
||||
|
||||
it.skipIf(isPlatform("win32"))(
|
||||
"does not scan unrelated directories for bounded globs",
|
||||
async () => {
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "packages/*/.runtime.env\n");
|
||||
mkdirSync(join(sourceRoot, "packages", "api"), { recursive: true });
|
||||
writeFileSync(join(sourceRoot, "packages", "api", ".runtime.env"), "api\n");
|
||||
const unreadableDirectory = join(sourceRoot, "unrelated");
|
||||
mkdirSync(unreadableDirectory);
|
||||
chmodSync(unreadableDirectory, 0o000);
|
||||
|
||||
try {
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
expect(plan.materializations).toEqual([
|
||||
expect.objectContaining({ relativePath: "packages/api/.runtime.env" }),
|
||||
]);
|
||||
} finally {
|
||||
chmodSync(unreadableDirectory, 0o700);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(isPlatform("win32") || process.getuid?.() === 0)(
|
||||
"skips inaccessible entries while retaining safe paths",
|
||||
async () => {
|
||||
const inaccessibleDirectory = join(sourceRoot, "private");
|
||||
mkdirSync(inaccessibleDirectory);
|
||||
writeFileSync(join(inaccessibleDirectory, "secret.txt"), "secret\n");
|
||||
writeFileSync(join(sourceRoot, "safe.txt"), "safe\n");
|
||||
writeFileSync(
|
||||
join(sourceRoot, ".worktreeinclude"),
|
||||
["private/**", "private/*", "safe.txt", ""].join("\n"),
|
||||
);
|
||||
chmodSync(inaccessibleDirectory, 0o000);
|
||||
|
||||
try {
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
|
||||
expect(plan.materializations).toEqual([
|
||||
expect.objectContaining({ relativePath: "safe.txt", sourceKind: "file" }),
|
||||
]);
|
||||
expect(plan.skipped).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ raw: "private/**", reason: "materialization" }),
|
||||
expect.objectContaining({ raw: "private/*", reason: "materialization" }),
|
||||
]),
|
||||
);
|
||||
} finally {
|
||||
chmodSync(inaccessibleDirectory, 0o700);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(isPlatform("win32") || process.getuid?.() === 0)(
|
||||
"retains valid glob matches when a viable sibling is unreadable",
|
||||
async () => {
|
||||
mkdirSync(join(sourceRoot, "packages", "good"), { recursive: true });
|
||||
mkdirSync(join(sourceRoot, "packages", "private"), { recursive: true });
|
||||
writeFileSync(join(sourceRoot, "packages", "good", ".runtime.env"), "good\n");
|
||||
writeFileSync(join(sourceRoot, "packages", "private", ".runtime.env"), "private\n");
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "packages/*/.runtime.env\n");
|
||||
chmodSync(join(sourceRoot, "packages", "private"), 0o000);
|
||||
|
||||
try {
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
expect(plan.materializations).toEqual([
|
||||
expect.objectContaining({ relativePath: "packages/good/.runtime.env" }),
|
||||
]);
|
||||
expect(plan.skipped).toEqual([
|
||||
expect.objectContaining({ raw: "packages/*/.runtime.env", reason: "materialization" }),
|
||||
]);
|
||||
} finally {
|
||||
chmodSync(join(sourceRoot, "packages", "private"), 0o700);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe.skipIf(isPlatform("win32"))("worktree include materialization", () => {
|
||||
let tempDir: string;
|
||||
let sourceRoot: string;
|
||||
let worktreeRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "worktree-include-materialize-test-"));
|
||||
sourceRoot = join(tempDir, "source");
|
||||
worktreeRoot = join(tempDir, "worktree");
|
||||
mkdirSync(sourceRoot);
|
||||
mkdirSync(worktreeRoot);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("copies snapshots, links shared paths, and is idempotent", async () => {
|
||||
writeFileSync(
|
||||
join(sourceRoot, ".worktreeinclude"),
|
||||
["copy.txt", "copy-dir/**", "symlink linked.txt", "symlink linked-dir"].join("\n"),
|
||||
);
|
||||
writeFileSync(join(sourceRoot, "copy.txt"), "copy-v1\n");
|
||||
mkdirSync(join(sourceRoot, "copy-dir"), { recursive: true });
|
||||
writeFileSync(join(sourceRoot, "copy-dir", "state.txt"), "copy-dir-v1\n");
|
||||
writeFileSync(join(sourceRoot, "linked.txt"), "linked-v1\n");
|
||||
mkdirSync(join(sourceRoot, "linked-dir"), { recursive: true });
|
||||
writeFileSync(join(sourceRoot, "linked-dir", "state.txt"), "linked-dir-v1\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
await materializeWorktreeIncludePlan({ plan, worktreeRoot });
|
||||
|
||||
expect(lstatSync(join(worktreeRoot, "copy.txt")).isSymbolicLink()).toBe(false);
|
||||
expect(lstatSync(join(worktreeRoot, "copy-dir")).isSymbolicLink()).toBe(false);
|
||||
expect(lstatSync(join(worktreeRoot, "linked.txt")).isSymbolicLink()).toBe(true);
|
||||
expect(lstatSync(join(worktreeRoot, "linked-dir")).isSymbolicLink()).toBe(true);
|
||||
|
||||
writeFileSync(join(sourceRoot, "copy.txt"), "copy-v2\n");
|
||||
writeFileSync(join(sourceRoot, "copy-dir", "state.txt"), "copy-dir-v2\n");
|
||||
writeFileSync(join(sourceRoot, "linked.txt"), "linked-v2\n");
|
||||
writeFileSync(join(sourceRoot, "linked-dir", "state.txt"), "linked-dir-v2\n");
|
||||
|
||||
expect(readFileSync(join(worktreeRoot, "copy.txt"), "utf8")).toBe("copy-v1\n");
|
||||
expect(readFileSync(join(worktreeRoot, "copy-dir", "state.txt"), "utf8")).toBe("copy-dir-v1\n");
|
||||
expect(readFileSync(join(worktreeRoot, "linked.txt"), "utf8")).toBe("linked-v2\n");
|
||||
expect(readFileSync(join(worktreeRoot, "linked-dir", "state.txt"), "utf8")).toBe(
|
||||
"linked-dir-v2\n",
|
||||
);
|
||||
|
||||
await materializeWorktreeIncludePlan({ plan, worktreeRoot });
|
||||
expect(readFileSync(join(worktreeRoot, "copy.txt"), "utf8")).toBe("copy-v2\n");
|
||||
expect(readFileSync(join(worktreeRoot, "copy-dir", "state.txt"), "utf8")).toBe("copy-dir-v2\n");
|
||||
});
|
||||
|
||||
it("replaces an existing directory snapshot without retaining destination-only files", async () => {
|
||||
mkdirSync(join(sourceRoot, "cache"));
|
||||
writeFileSync(join(sourceRoot, "cache", "current.txt"), "current\n");
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "cache/**\n");
|
||||
mkdirSync(join(worktreeRoot, "cache"));
|
||||
writeFileSync(join(worktreeRoot, "cache", "stale.txt"), "stale\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot });
|
||||
|
||||
expect(result).toMatchObject({ materialized: 1, skipped: [] });
|
||||
expect(readFileSync(join(worktreeRoot, "cache", "current.txt"), "utf8")).toBe("current\n");
|
||||
expect(existsSync(join(worktreeRoot, "cache", "stale.txt"))).toBe(false);
|
||||
});
|
||||
|
||||
it("retains an explicit descendant when an overlapping directory copy is skipped", async () => {
|
||||
mkdirSync(join(sourceRoot, "config", "nested"), { recursive: true });
|
||||
writeFileSync(join(sourceRoot, "config", "local.env"), "local\n");
|
||||
writeFileSync(join(sourceRoot, "config", "nested", "state.txt"), "state\n");
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "config/**\nconfig/local.env\n");
|
||||
mkdirSync(join(worktreeRoot, "config"));
|
||||
writeFileSync(join(worktreeRoot, "config", "nested"), "conflict\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot });
|
||||
|
||||
expect(result).toMatchObject({ materialized: 1 });
|
||||
expect(result.skipped).toEqual([
|
||||
expect.objectContaining({ raw: "config/**", reason: "conflict" }),
|
||||
]);
|
||||
expect(readFileSync(join(worktreeRoot, "config", "local.env"), "utf8")).toBe("local\n");
|
||||
});
|
||||
|
||||
it("skips a destination parent symlink without writing through it", async () => {
|
||||
mkdirSync(join(sourceRoot, "config"), { recursive: true });
|
||||
writeFileSync(join(sourceRoot, "config", "local.json"), "{}\n");
|
||||
writeFileSync(join(sourceRoot, "safe.txt"), "safe\n");
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "config/local.json\nsafe.txt\n");
|
||||
|
||||
const outsideRoot = join(tempDir, "outside");
|
||||
mkdirSync(outsideRoot);
|
||||
symlinkSync(outsideRoot, join(worktreeRoot, "config"));
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot });
|
||||
|
||||
expect(existsSync(join(outsideRoot, "local.json"))).toBe(false);
|
||||
expect(readFileSync(join(worktreeRoot, "safe.txt"), "utf8")).toBe("safe\n");
|
||||
expect(result).toMatchObject({ materialized: 1 });
|
||||
expect(result.skipped).toEqual([
|
||||
expect.objectContaining({ raw: "config/local.json", reason: "conflict" }),
|
||||
]);
|
||||
expect(readdirSync(worktreeRoot)).not.toContain(
|
||||
expect.stringMatching(/^\.paseo-worktreeinclude-/),
|
||||
);
|
||||
});
|
||||
|
||||
it("copies resolved source links and creates direct live links", async () => {
|
||||
const targetPath = join(sourceRoot, "target.txt");
|
||||
const targetDirectoryPath = join(sourceRoot, "target-directory");
|
||||
writeFileSync(targetPath, "v1\n");
|
||||
mkdirSync(targetDirectoryPath);
|
||||
writeFileSync(join(targetDirectoryPath, "state.txt"), "v1\n");
|
||||
symlinkSync(targetPath, join(sourceRoot, "copy-link.txt"));
|
||||
symlinkSync(targetPath, join(sourceRoot, "live-link.txt"));
|
||||
symlinkSync(targetDirectoryPath, join(sourceRoot, "copy-directory-link"), "dir");
|
||||
symlinkSync(targetDirectoryPath, join(sourceRoot, "live-directory-link"), "dir");
|
||||
writeFileSync(
|
||||
join(sourceRoot, ".worktreeinclude"),
|
||||
[
|
||||
"copy-link.txt",
|
||||
"symlink live-link.txt",
|
||||
"copy-directory-link",
|
||||
"symlink live-directory-link",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot });
|
||||
|
||||
const copiedPath = join(worktreeRoot, "copy-link.txt");
|
||||
const linkedPath = join(worktreeRoot, "live-link.txt");
|
||||
const copiedDirectoryPath = join(worktreeRoot, "copy-directory-link");
|
||||
const linkedDirectoryPath = join(worktreeRoot, "live-directory-link");
|
||||
const canonicalWorktreeRoot = realpathSync(worktreeRoot);
|
||||
expect(result).toMatchObject({ materialized: 4, skipped: [] });
|
||||
expect(lstatSync(copiedPath).isSymbolicLink()).toBe(false);
|
||||
expect(lstatSync(linkedPath).isSymbolicLink()).toBe(true);
|
||||
expect(lstatSync(copiedDirectoryPath).isSymbolicLink()).toBe(false);
|
||||
expect(lstatSync(linkedDirectoryPath).isSymbolicLink()).toBe(true);
|
||||
expect(readlinkSync(linkedPath)).toBe(
|
||||
relative(dirname(join(canonicalWorktreeRoot, "live-link.txt")), realpathSync(targetPath)),
|
||||
);
|
||||
expect(readlinkSync(linkedDirectoryPath)).toBe(
|
||||
relative(
|
||||
dirname(join(canonicalWorktreeRoot, "live-directory-link")),
|
||||
realpathSync(targetDirectoryPath),
|
||||
),
|
||||
);
|
||||
expect(realpathSync(linkedPath)).toBe(realpathSync(targetPath));
|
||||
expect(realpathSync(linkedDirectoryPath)).toBe(realpathSync(targetDirectoryPath));
|
||||
|
||||
writeFileSync(targetPath, "v2\n");
|
||||
writeFileSync(join(targetDirectoryPath, "state.txt"), "v2\n");
|
||||
expect(readFileSync(copiedPath, "utf8")).toBe("v1\n");
|
||||
expect(readFileSync(linkedPath, "utf8")).toBe("v2\n");
|
||||
expect(readFileSync(join(copiedDirectoryPath, "state.txt"), "utf8")).toBe("v1\n");
|
||||
expect(readFileSync(join(linkedDirectoryPath, "state.txt"), "utf8")).toBe("v2\n");
|
||||
});
|
||||
|
||||
it("rejects source links that alias Git metadata", async () => {
|
||||
mkdirSync(join(sourceRoot, ".git"));
|
||||
writeFileSync(join(sourceRoot, ".git", "config"), "secret\n");
|
||||
symlinkSync(join(sourceRoot, ".git"), join(sourceRoot, "metadata"), "dir");
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "metadata\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
|
||||
expect(plan.materializations).toEqual([]);
|
||||
expect(plan.skipped).toEqual([expect.objectContaining({ raw: "metadata", reason: "unsafe" })]);
|
||||
});
|
||||
|
||||
it("treats hard links as ordinary files", async () => {
|
||||
const targetPath = join(sourceRoot, "target.txt");
|
||||
const hardLinkPath = join(sourceRoot, "hard-link.txt");
|
||||
writeFileSync(targetPath, "v1\n");
|
||||
linkSync(targetPath, hardLinkPath);
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "hard-link.txt\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
await materializeWorktreeIncludePlan({ plan, worktreeRoot });
|
||||
|
||||
const copiedPath = join(worktreeRoot, "hard-link.txt");
|
||||
expect(lstatSync(copiedPath).isSymbolicLink()).toBe(false);
|
||||
expect(readFileSync(copiedPath, "utf8")).toBe("v1\n");
|
||||
});
|
||||
|
||||
it("skips a source link retargeted outside the checkout after planning", async () => {
|
||||
const insidePath = join(sourceRoot, "inside.txt");
|
||||
const linkedPath = join(sourceRoot, "linked.txt");
|
||||
const outsidePath = join(tempDir, "outside.txt");
|
||||
writeFileSync(insidePath, "inside\n");
|
||||
writeFileSync(outsidePath, "outside\n");
|
||||
symlinkSync(insidePath, linkedPath);
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "symlink linked.txt\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
rmSync(linkedPath);
|
||||
symlinkSync(outsidePath, linkedPath);
|
||||
|
||||
const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot });
|
||||
|
||||
expect(existsSync(join(worktreeRoot, "linked.txt"))).toBe(false);
|
||||
expect(result.skipped).toEqual([
|
||||
expect.objectContaining({ raw: "symlink linked.txt", reason: "unsafe" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips a linked directory that exposes an external nested link", async () => {
|
||||
const sharedPath = join(sourceRoot, "shared");
|
||||
const outsidePath = join(tempDir, "outside.txt");
|
||||
mkdirSync(sharedPath);
|
||||
writeFileSync(outsidePath, "outside\n");
|
||||
symlinkSync(outsidePath, join(sharedPath, "outside.txt"));
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "symlink shared\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
|
||||
expect(plan.materializations).toEqual([]);
|
||||
expect(plan.skipped).toEqual([
|
||||
expect.objectContaining({ raw: "symlink shared", reason: "unsafe" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("allows a linked directory with internal nested links", async () => {
|
||||
const sharedPath = join(sourceRoot, "shared");
|
||||
const targetPath = join(sourceRoot, "shared-target");
|
||||
mkdirSync(sharedPath);
|
||||
mkdirSync(targetPath);
|
||||
writeFileSync(join(targetPath, "state.txt"), "source\n");
|
||||
symlinkSync(targetPath, join(sharedPath, "target"), "dir");
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "symlink shared\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot });
|
||||
|
||||
expect(result).toMatchObject({ materialized: 1, skipped: [] });
|
||||
expect(readFileSync(join(worktreeRoot, "shared", "target", "state.txt"), "utf8")).toBe(
|
||||
"source\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("skips a source removed after planning", async () => {
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "runtime.env\n");
|
||||
const sourcePath = join(sourceRoot, "runtime.env");
|
||||
writeFileSync(sourcePath, "source\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
rmSync(sourcePath);
|
||||
|
||||
const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot });
|
||||
|
||||
expect(existsSync(join(worktreeRoot, "runtime.env"))).toBe(false);
|
||||
expect(result.skipped).toEqual([
|
||||
expect.objectContaining({ raw: "runtime.env", reason: "missing" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it.skipIf(process.getuid?.() === 0)(
|
||||
"skips ordinary filesystem failures during materialization revalidation",
|
||||
async () => {
|
||||
mkdirSync(join(sourceRoot, "private"));
|
||||
writeFileSync(join(sourceRoot, "private", "state.txt"), "private\n");
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "private/**\n");
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
chmodSync(join(sourceRoot, "private"), 0o000);
|
||||
|
||||
try {
|
||||
const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot });
|
||||
expect(result).toMatchObject({ materialized: 0 });
|
||||
expect(result.skipped).toEqual([
|
||||
expect.objectContaining({ raw: "private/**", reason: "materialization" }),
|
||||
]);
|
||||
} finally {
|
||||
chmodSync(join(sourceRoot, "private"), 0o700);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe.skipIf(!isPlatform("win32"))("worktree include Windows directory links", () => {
|
||||
let tempDir: string;
|
||||
let sourceRoot: string;
|
||||
let worktreeRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "worktree-include-windows-test-"));
|
||||
sourceRoot = join(tempDir, "source");
|
||||
worktreeRoot = join(tempDir, "worktree");
|
||||
mkdirSync(sourceRoot);
|
||||
mkdirSync(worktreeRoot);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("uses a live directory link and removes it without touching the source", async () => {
|
||||
mkdirSync(join(sourceRoot, "shared-state"));
|
||||
writeFileSync(join(sourceRoot, "shared-state", "state.txt"), "source-v1\n");
|
||||
writeFileSync(join(sourceRoot, ".worktreeinclude"), "symlink shared-state\n");
|
||||
|
||||
const plan = await readWorktreeIncludePlan({ sourceRoot });
|
||||
await materializeWorktreeIncludePlan({ plan, worktreeRoot });
|
||||
|
||||
writeFileSync(join(sourceRoot, "shared-state", "state.txt"), "source-v2\n");
|
||||
expect(readFileSync(join(worktreeRoot, "shared-state", "state.txt"), "utf8")).toBe(
|
||||
"source-v2\n",
|
||||
);
|
||||
|
||||
rmSync(worktreeRoot, { recursive: true, force: true });
|
||||
expect(readFileSync(join(sourceRoot, "shared-state", "state.txt"), "utf8")).toBe("source-v2\n");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,7 @@ import {
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
chmodSync,
|
||||
lstatSync,
|
||||
} from "fs";
|
||||
import { delimiter, dirname, join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
@@ -362,6 +363,68 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
expect(metadata).toMatchObject({ baseRefName: "main" });
|
||||
});
|
||||
|
||||
it("removes fetched branches when include planning fails", 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/cleanup"], { cwd: remoteCloneDir });
|
||||
writeFileSync(join(remoteCloneDir, "file.txt"), "from-pr\n");
|
||||
execFileSync("git", ["add", "file.txt"], { 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/cleanup"], { cwd: remoteCloneDir });
|
||||
execFileSync("git", [`--git-dir=${remoteDir}`, "update-ref", "refs/pull/44/head", prHead]);
|
||||
mkdirSync(join(repoDir, ".worktreeinclude"));
|
||||
|
||||
await expect(
|
||||
createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "pr-44-cleanup",
|
||||
source: {
|
||||
kind: "checkout-github-pr",
|
||||
githubPrNumber: 44,
|
||||
headRef: "contributor/cleanup",
|
||||
baseRefName: "main",
|
||||
},
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "EISDIR" });
|
||||
|
||||
expect(() =>
|
||||
execFileSync("git", ["show-ref", "--verify", "--quiet", "refs/heads/contributor/cleanup"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
}),
|
||||
).toThrow();
|
||||
|
||||
await expect(
|
||||
createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "branch-cleanup",
|
||||
source: { kind: "checkout-branch", branchName: "contributor/cleanup" },
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "EISDIR" });
|
||||
|
||||
expect(() =>
|
||||
execFileSync("git", ["show-ref", "--verify", "--quiet", "refs/heads/contributor/cleanup"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
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");
|
||||
@@ -1062,6 +1125,241 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("materializes copies and symlinks before setup, then removes only the new worktree links", async () => {
|
||||
writeFileSync(
|
||||
join(repoDir, ".gitignore"),
|
||||
[".copy.env", "copy-cache/", "linked-file.txt", "linked-state", "setup.log", ""].join("\n"),
|
||||
);
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
setup: [
|
||||
"test -f .copy.env",
|
||||
"test -L linked-file.txt",
|
||||
"test -L linked-state",
|
||||
"cat linked-state/state.txt > setup.log",
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
execFileSync("git", ["add", ".gitignore", "paseo.json"], { cwd: repoDir });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add include fixture"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
|
||||
writeFileSync(
|
||||
join(repoDir, ".worktreeinclude"),
|
||||
[".copy.env", "copy-cache/**", "symlink linked-file.txt", "symlink linked-state", ""].join(
|
||||
"\n",
|
||||
),
|
||||
);
|
||||
writeFileSync(join(repoDir, ".copy.env"), "copy-v1\n");
|
||||
mkdirSync(join(repoDir, "copy-cache"), { recursive: true });
|
||||
writeFileSync(join(repoDir, "copy-cache", "state.txt"), "copy-cache-v1\n");
|
||||
writeFileSync(join(repoDir, "linked-file.txt"), "linked-file-v1\n");
|
||||
mkdirSync(join(repoDir, "linked-state"), { recursive: true });
|
||||
writeFileSync(join(repoDir, "linked-state", "state.txt"), "linked-state-v1\n");
|
||||
|
||||
const result = await createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "include-links",
|
||||
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/include-links" },
|
||||
runSetup: true,
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
expect(readFileSync(join(result.worktreePath, "setup.log"), "utf8")).toBe(
|
||||
"linked-state-v1\n",
|
||||
);
|
||||
expect(lstatSync(join(result.worktreePath, ".copy.env")).isSymbolicLink()).toBe(false);
|
||||
expect(lstatSync(join(result.worktreePath, "copy-cache")).isSymbolicLink()).toBe(false);
|
||||
expect(lstatSync(join(result.worktreePath, "linked-file.txt")).isSymbolicLink()).toBe(true);
|
||||
expect(lstatSync(join(result.worktreePath, "linked-state")).isSymbolicLink()).toBe(true);
|
||||
expect(
|
||||
execFileSync("git", ["status", "--porcelain"], {
|
||||
cwd: result.worktreePath,
|
||||
encoding: "utf8",
|
||||
}),
|
||||
).toBe("");
|
||||
|
||||
writeFileSync(join(repoDir, ".copy.env"), "copy-v2\n");
|
||||
writeFileSync(join(repoDir, "copy-cache", "state.txt"), "copy-cache-v2\n");
|
||||
writeFileSync(join(repoDir, "linked-file.txt"), "linked-file-v2\n");
|
||||
writeFileSync(join(repoDir, "linked-state", "state.txt"), "linked-state-v2\n");
|
||||
|
||||
expect(readFileSync(join(result.worktreePath, ".copy.env"), "utf8")).toBe("copy-v1\n");
|
||||
expect(readFileSync(join(result.worktreePath, "copy-cache", "state.txt"), "utf8")).toBe(
|
||||
"copy-cache-v1\n",
|
||||
);
|
||||
expect(readFileSync(join(result.worktreePath, "linked-file.txt"), "utf8")).toBe(
|
||||
"linked-file-v2\n",
|
||||
);
|
||||
expect(readFileSync(join(result.worktreePath, "linked-state", "state.txt"), "utf8")).toBe(
|
||||
"linked-state-v2\n",
|
||||
);
|
||||
|
||||
await deletePaseoWorktree({
|
||||
cwd: repoDir,
|
||||
worktreePath: result.worktreePath,
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
expect(existsSync(result.worktreePath)).toBe(false);
|
||||
expect(readFileSync(join(repoDir, "linked-file.txt"), "utf8")).toBe("linked-file-v2\n");
|
||||
expect(readFileSync(join(repoDir, "linked-state", "state.txt"), "utf8")).toBe(
|
||||
"linked-state-v2\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("skips missing includes and materializes paths that exist", async () => {
|
||||
const projectHash = await deriveWorktreeProjectHash(repoDir);
|
||||
const expectedWorktreePath = join(paseoHome, "worktrees", projectHash, "missing-include");
|
||||
writeFileSync(
|
||||
join(repoDir, ".worktreeinclude"),
|
||||
[".env", ".env.local", ".sops.yaml", ""].join("\n"),
|
||||
);
|
||||
writeFileSync(join(repoDir, ".env"), "present\n");
|
||||
|
||||
const result = await createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "missing-include",
|
||||
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/missing-include" },
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
expect(result.worktreePath).toBe(expectedWorktreePath);
|
||||
expect(readFileSync(join(result.worktreePath, ".env"), "utf8")).toBe("present\n");
|
||||
expect(existsSync(join(result.worktreePath, ".env.local"))).toBe(false);
|
||||
expect(existsSync(join(result.worktreePath, ".sops.yaml"))).toBe(false);
|
||||
expect(
|
||||
execFileSync("git", ["worktree", "list", "--porcelain"], {
|
||||
cwd: repoDir,
|
||||
encoding: "utf8",
|
||||
}),
|
||||
).toContain(expectedWorktreePath);
|
||||
});
|
||||
|
||||
it("skips checkout-local worktree storage and creates the remaining includes", async () => {
|
||||
const checkoutLocalPaseoHome = join(repoDir, ".dev", "paseo-home");
|
||||
const projectHash = await deriveWorktreeProjectHash(repoDir);
|
||||
const expectedWorktreePath = join(
|
||||
checkoutLocalPaseoHome,
|
||||
"worktrees",
|
||||
projectHash,
|
||||
"protected-include",
|
||||
);
|
||||
mkdirSync(join(checkoutLocalPaseoHome, "worktrees", projectHash), { recursive: true });
|
||||
writeFileSync(join(repoDir, ".worktreeinclude"), [".dev/**", ".env", ""].join("\n"));
|
||||
writeFileSync(join(repoDir, ".env"), "present\n");
|
||||
|
||||
const result = await createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "protected-include",
|
||||
source: {
|
||||
kind: "branch-off",
|
||||
baseBranch: "main",
|
||||
branchName: "feature/protected-include",
|
||||
},
|
||||
runSetup: false,
|
||||
paseoHome: checkoutLocalPaseoHome,
|
||||
});
|
||||
|
||||
expect(result.worktreePath).toBe(expectedWorktreePath);
|
||||
expect(readFileSync(join(result.worktreePath, ".env"), "utf8")).toBe("present\n");
|
||||
expect(result.worktreeIncludeSummary?.skipped).toEqual([
|
||||
expect.objectContaining({ raw: ".dev/**", reason: "unsafe" }),
|
||||
]);
|
||||
expect(
|
||||
execFileSync("git", ["worktree", "list", "--porcelain"], {
|
||||
cwd: repoDir,
|
||||
encoding: "utf8",
|
||||
}),
|
||||
).toContain(expectedWorktreePath);
|
||||
expect(
|
||||
execFileSync("git", ["branch", "--list", "feature/protected-include"], {
|
||||
cwd: repoDir,
|
||||
encoding: "utf8",
|
||||
}).trim(),
|
||||
).toContain("feature/protected-include");
|
||||
});
|
||||
|
||||
it("keeps includes when branching from a Paseo-managed worktree", async () => {
|
||||
writeFileSync(join(repoDir, ".gitignore"), ".env\n");
|
||||
writeFileSync(join(repoDir, ".worktreeinclude"), ".env\n");
|
||||
execFileSync("git", ["add", ".gitignore", ".worktreeinclude"], { cwd: repoDir });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add include config"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
writeFileSync(join(repoDir, ".env"), "source\n");
|
||||
|
||||
const sourceWorktree = await createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "include-source",
|
||||
source: {
|
||||
kind: "branch-off",
|
||||
baseBranch: "main",
|
||||
branchName: "feature/include-source",
|
||||
},
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
const nestedWorktree = await createLegacyWorktreeForTest({
|
||||
cwd: sourceWorktree.worktreePath,
|
||||
worktreeSlug: "include-nested",
|
||||
source: {
|
||||
kind: "branch-off",
|
||||
baseBranch: "feature/include-source",
|
||||
branchName: "feature/include-nested",
|
||||
},
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
expect(readFileSync(join(sourceWorktree.worktreePath, ".env"), "utf8")).toBe("source\n");
|
||||
expect(readFileSync(join(nestedWorktree.worktreePath, ".env"), "utf8")).toBe("source\n");
|
||||
expect(nestedWorktree.worktreeIncludeSummary?.skipped).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips a symlink include conflict and keeps the new worktree", async () => {
|
||||
const projectHash = await deriveWorktreeProjectHash(repoDir);
|
||||
const expectedWorktreePath = join(paseoHome, "worktrees", projectHash, "include-conflict");
|
||||
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify({ scripts: {} }));
|
||||
writeFileSync(join(repoDir, ".worktreeinclude"), "symlink paseo.json\n");
|
||||
|
||||
const result = await createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "include-conflict",
|
||||
source: {
|
||||
kind: "branch-off",
|
||||
baseBranch: "main",
|
||||
branchName: "feature/include-conflict",
|
||||
},
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
expect(result.worktreePath).toBe(expectedWorktreePath);
|
||||
expect(existsSync(expectedWorktreePath)).toBe(true);
|
||||
expect(lstatSync(join(expectedWorktreePath, "paseo.json")).isSymbolicLink()).toBe(false);
|
||||
expect(result.worktreeIncludeSummary?.skipped).toEqual([
|
||||
expect.objectContaining({ raw: "symlink paseo.json", reason: "conflict" }),
|
||||
]);
|
||||
expect(
|
||||
execFileSync("git", ["worktree", "list", "--porcelain"], {
|
||||
cwd: repoDir,
|
||||
encoding: "utf8",
|
||||
}),
|
||||
).toContain(expectedWorktreePath);
|
||||
expect(
|
||||
execFileSync("git", ["branch", "--list", "feature/include-conflict"], {
|
||||
cwd: repoDir,
|
||||
encoding: "utf8",
|
||||
}).trim(),
|
||||
).toContain("feature/include-conflict");
|
||||
});
|
||||
|
||||
it("creates a worktree without error when no paseo.json exists in the main repo", async () => {
|
||||
const result = await createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { existsSync, mkdirSync, realpathSync, rmSync, statSync } from "fs";
|
||||
import { copyFile, rm, stat } from "fs/promises";
|
||||
import { join, basename, dirname, isAbsolute, resolve, sep } from "path";
|
||||
import net from "node:net";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import stripAnsi from "strip-ansi";
|
||||
import {
|
||||
buildStringCommandShellInvocation,
|
||||
@@ -36,6 +36,11 @@ import { createExternalProcessEnv } from "../server/paseo-env.js";
|
||||
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
|
||||
import { validateBranchSlug } from "@getpaseo/protocol/branch-slug";
|
||||
import { expandTilde, getRealpathAwareRelativePath, isPathInsideRoot } from "./path.js";
|
||||
import {
|
||||
materializeWorktreeIncludePlan,
|
||||
readWorktreeIncludePlan,
|
||||
type WorktreeIncludeSummary,
|
||||
} from "./worktree-include.js";
|
||||
|
||||
export { slugify, validateBranchSlug } from "@getpaseo/protocol/branch-slug";
|
||||
|
||||
@@ -49,6 +54,10 @@ export interface WorktreeConfig {
|
||||
worktreePath: string;
|
||||
}
|
||||
|
||||
export interface CreatedWorktreeConfig extends WorktreeConfig {
|
||||
worktreeIncludeSummary: WorktreeIncludeSummary;
|
||||
}
|
||||
|
||||
export interface WorktreeRuntimeEnv {
|
||||
[key: string]: string;
|
||||
PASEO_SOURCE_CHECKOUT_PATH: string;
|
||||
@@ -1161,13 +1170,67 @@ export async function deletePaseoWorktree({
|
||||
}
|
||||
}
|
||||
|
||||
export interface RollbackCreatedPaseoWorktreeOptions extends DeletePaseoWorktreeOptions {
|
||||
createdBranchName?: string;
|
||||
expectedOid?: string;
|
||||
}
|
||||
|
||||
async function removeCreatedWorktreeBranch(options: {
|
||||
createdBranchName?: string;
|
||||
expectedOid?: string;
|
||||
cwd?: string | null;
|
||||
}): Promise<void> {
|
||||
if (!options.createdBranchName || !options.cwd) {
|
||||
return;
|
||||
}
|
||||
if (!(await localBranchExists(options.cwd, options.createdBranchName))) {
|
||||
return;
|
||||
}
|
||||
if (options.expectedOid) {
|
||||
await runGitCommand(
|
||||
["update-ref", "-d", `refs/heads/${options.createdBranchName}`, options.expectedOid],
|
||||
{ cwd: options.cwd },
|
||||
);
|
||||
} else {
|
||||
await runGitCommand(["branch", "--delete", "--force", options.createdBranchName], {
|
||||
cwd: options.cwd,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function rollbackCreatedWorktreeBranch(
|
||||
options: {
|
||||
createdBranchName?: string;
|
||||
expectedOid?: string;
|
||||
cwd: string;
|
||||
},
|
||||
cause: unknown,
|
||||
): Promise<never> {
|
||||
let cleanupError: unknown;
|
||||
try {
|
||||
await removeCreatedWorktreeBranch(options);
|
||||
} catch (error) {
|
||||
cleanupError = error;
|
||||
}
|
||||
if (cleanupError) {
|
||||
const failure = new Error(
|
||||
`${cause instanceof Error ? cause.message : "Worktree workflow failed"}; rollback also failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`,
|
||||
{ cause },
|
||||
);
|
||||
Object.assign(failure, { cleanupError });
|
||||
throw failure;
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
|
||||
export async function rollbackCreatedPaseoWorktree(
|
||||
options: DeletePaseoWorktreeOptions,
|
||||
options: RollbackCreatedPaseoWorktreeOptions,
|
||||
cause: unknown,
|
||||
): Promise<never> {
|
||||
let cleanupError: unknown;
|
||||
try {
|
||||
await deletePaseoWorktree(options);
|
||||
await removeCreatedWorktreeBranch(options);
|
||||
} catch (error) {
|
||||
cleanupError = error;
|
||||
}
|
||||
@@ -1233,50 +1296,100 @@ export const createWorktree = async ({
|
||||
runSetup,
|
||||
paseoHome,
|
||||
worktreesRoot,
|
||||
}: CreateWorktreeOptions): Promise<WorktreeConfig> => {
|
||||
}: CreateWorktreeOptions): Promise<CreatedWorktreeConfig> => {
|
||||
const sourcePlan = await resolveWorktreeSourcePlan({ cwd, source, desiredSlug: worktreeSlug });
|
||||
let worktreePath = join(await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot), worktreeSlug);
|
||||
mkdirSync(dirname(worktreePath), { recursive: true });
|
||||
const { worktreeIncludePlan, worktreePath } = await (async () => {
|
||||
try {
|
||||
const paseoWorktreesBaseRoot = resolvePaseoWorktreesBaseRoot({ paseoHome, worktreesRoot });
|
||||
const paseoWorktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot);
|
||||
const includePlan = await readWorktreeIncludePlan({
|
||||
sourceRoot: cwd,
|
||||
excludedSourceRoots: [paseoWorktreesBaseRoot],
|
||||
});
|
||||
const requestedWorktreePath = join(paseoWorktreesRoot, worktreeSlug);
|
||||
mkdirSync(dirname(requestedWorktreePath), { recursive: true });
|
||||
|
||||
// Also handle worktree path collision
|
||||
let finalWorktreePath = worktreePath;
|
||||
let pathSuffix = 1;
|
||||
while (existsSync(finalWorktreePath)) {
|
||||
finalWorktreePath = `${worktreePath}-${pathSuffix}`;
|
||||
pathSuffix++;
|
||||
}
|
||||
// Also handle worktree path collision
|
||||
let finalWorktreePath = requestedWorktreePath;
|
||||
let pathSuffix = 1;
|
||||
while (existsSync(finalWorktreePath)) {
|
||||
finalWorktreePath = `${requestedWorktreePath}-${pathSuffix}`;
|
||||
pathSuffix++;
|
||||
}
|
||||
|
||||
// Primitive owner for `git worktree add`; callers route through createWorktreeCore.
|
||||
await runGitCommand(["worktree", "add", finalWorktreePath, ...sourcePlan.addArguments], {
|
||||
cwd,
|
||||
timeout: 120_000,
|
||||
});
|
||||
worktreePath = normalizePathForOwnership(finalWorktreePath);
|
||||
// Primitive owner for `git worktree add`; callers route through createWorktreeCore.
|
||||
await runGitCommand(["worktree", "add", finalWorktreePath, ...sourcePlan.addArguments], {
|
||||
cwd,
|
||||
timeout: 120_000,
|
||||
});
|
||||
|
||||
if (sourcePlan.pushRemote) {
|
||||
await configureWorktreePushRemote({
|
||||
cwd,
|
||||
branchName: sourcePlan.branchName,
|
||||
remote: sourcePlan.pushRemote,
|
||||
return {
|
||||
worktreeIncludePlan: includePlan,
|
||||
worktreePath: normalizePathForOwnership(finalWorktreePath),
|
||||
};
|
||||
} catch (error) {
|
||||
return rollbackCreatedWorktreeBranch(
|
||||
{
|
||||
cwd,
|
||||
createdBranchName: sourcePlan.createdBranchNameBeforeWorktreeAdd,
|
||||
expectedOid: sourcePlan.createdBranchOidBeforeWorktreeAdd,
|
||||
},
|
||||
error,
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
let worktreeIncludeSummary: WorktreeIncludeSummary = {
|
||||
materialized: 0,
|
||||
skipped: [...worktreeIncludePlan.skipped],
|
||||
};
|
||||
try {
|
||||
if (sourcePlan.pushRemote) {
|
||||
await configureWorktreePushRemote({
|
||||
cwd,
|
||||
branchName: sourcePlan.branchName,
|
||||
remote: sourcePlan.pushRemote,
|
||||
});
|
||||
}
|
||||
if (sourcePlan.trackingRemote) {
|
||||
await configureWorktreeTrackingRemote({
|
||||
cwd,
|
||||
branchName: sourcePlan.branchName,
|
||||
remote: sourcePlan.trackingRemote,
|
||||
});
|
||||
}
|
||||
|
||||
writePaseoWorktreeMetadata(worktreePath, {
|
||||
baseRefName: sourcePlan.metadataBaseRefName,
|
||||
...(sourcePlan.changeRequestLookupTarget
|
||||
? { changeRequestLookupTarget: sourcePlan.changeRequestLookupTarget }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
if (sourcePlan.trackingRemote) {
|
||||
await configureWorktreeTrackingRemote({
|
||||
cwd,
|
||||
branchName: sourcePlan.branchName,
|
||||
remote: sourcePlan.trackingRemote,
|
||||
|
||||
await seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath });
|
||||
const materialization = await materializeWorktreeIncludePlan({
|
||||
plan: worktreeIncludePlan,
|
||||
worktreeRoot: worktreePath,
|
||||
});
|
||||
worktreeIncludeSummary = {
|
||||
materialized: materialization.materialized,
|
||||
skipped: [...worktreeIncludePlan.skipped, ...materialization.skipped],
|
||||
};
|
||||
} catch (error) {
|
||||
await rollbackCreatedPaseoWorktree(
|
||||
{
|
||||
cwd,
|
||||
worktreePath,
|
||||
teardownCwds: [],
|
||||
paseoHome,
|
||||
worktreesBaseRoot: worktreesRoot,
|
||||
createdBranchName: sourcePlan.createdBranchName,
|
||||
expectedOid: sourcePlan.createdBranchOidBeforeWorktreeAdd,
|
||||
},
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
writePaseoWorktreeMetadata(worktreePath, {
|
||||
baseRefName: sourcePlan.metadataBaseRefName,
|
||||
...(sourcePlan.changeRequestLookupTarget
|
||||
? { changeRequestLookupTarget: sourcePlan.changeRequestLookupTarget }
|
||||
: {}),
|
||||
});
|
||||
|
||||
await seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath });
|
||||
|
||||
if (runSetup) {
|
||||
await runWorktreeSetupCommands({
|
||||
worktreePath,
|
||||
@@ -1287,6 +1400,7 @@ export const createWorktree = async ({
|
||||
|
||||
return {
|
||||
branchName: sourcePlan.branchName,
|
||||
worktreeIncludeSummary,
|
||||
worktreePath,
|
||||
};
|
||||
};
|
||||
@@ -1299,6 +1413,9 @@ interface ResolveWorktreeSourcePlanOptions {
|
||||
|
||||
interface WorktreeSourcePlan {
|
||||
branchName: string;
|
||||
createdBranchName?: string;
|
||||
createdBranchNameBeforeWorktreeAdd?: string;
|
||||
createdBranchOidBeforeWorktreeAdd?: string;
|
||||
metadataBaseRefName: string;
|
||||
changeRequestLookupTarget?: PaseoWorktreeChangeRequestLookupTarget;
|
||||
addArguments: string[];
|
||||
@@ -1314,6 +1431,11 @@ interface WorktreeSourcePlan {
|
||||
};
|
||||
}
|
||||
|
||||
type ChangeRequestWorktreeSource = Extract<
|
||||
WorktreeSource,
|
||||
{ kind: "checkout-change-request" | "checkout-github-pr" }
|
||||
>;
|
||||
|
||||
async function resolveWorktreeSourcePlan({
|
||||
cwd,
|
||||
source,
|
||||
@@ -1332,94 +1454,144 @@ async function resolveWorktreeSourcePlan({
|
||||
|
||||
return {
|
||||
branchName: newBranchName,
|
||||
createdBranchName: newBranchName,
|
||||
metadataBaseRefName: normalizedBaseBranch,
|
||||
addArguments: ["-b", newBranchName, "--no-track", base],
|
||||
};
|
||||
}
|
||||
case "checkout-branch": {
|
||||
await validateExistingWorktreeBranchName(cwd, source.branchName);
|
||||
if (!(await localBranchExists(cwd, source.branchName))) {
|
||||
try {
|
||||
await runGitCommand(["fetch", "origin", `${source.branchName}:${source.branchName}`], {
|
||||
cwd,
|
||||
timeout: 120_000,
|
||||
});
|
||||
} catch {
|
||||
throw new UnknownBranchError({ branchName: source.branchName, cwd });
|
||||
}
|
||||
}
|
||||
if (await isBranchCheckedOut(cwd, source.branchName)) {
|
||||
throw new BranchAlreadyCheckedOutError(source.branchName);
|
||||
}
|
||||
|
||||
return {
|
||||
branchName: source.branchName,
|
||||
metadataBaseRefName: source.branchName,
|
||||
addArguments: [source.branchName],
|
||||
};
|
||||
}
|
||||
case "checkout-branch":
|
||||
return resolveCheckoutBranchWorktreeSourcePlan({ cwd, branchName: source.branchName });
|
||||
case "checkout-change-request":
|
||||
case "checkout-github-pr": {
|
||||
const localBranchCandidate = source.localBranchName ?? source.headRef;
|
||||
await validateExistingWorktreeBranchName(cwd, localBranchCandidate);
|
||||
const localBranchName = await resolveUniqueLocalBranchName(cwd, localBranchCandidate);
|
||||
const normalizedBaseRefName = normalizeRequiredBaseBranch(source.baseRefName);
|
||||
const changeRequestNumber =
|
||||
source.kind === "checkout-github-pr" ? source.githubPrNumber : source.changeRequestNumber;
|
||||
await fetchWorktreeCheckoutRefs({
|
||||
cwd,
|
||||
localBranchName,
|
||||
checkoutRefs: source.checkoutRefs ?? [
|
||||
{ remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` },
|
||||
],
|
||||
});
|
||||
const shouldTrackOriginHead = source.trackOriginHead === true;
|
||||
const trackingRemote = shouldTrackOriginHead
|
||||
? await tryFetchWorktreeTrackingRemote({
|
||||
cwd,
|
||||
remoteName: "origin",
|
||||
headRef: source.headRef,
|
||||
})
|
||||
: undefined;
|
||||
const remotePlan: Pick<WorktreeSourcePlan, "pushRemote" | "trackingRemote"> = {};
|
||||
if (source.pushRemoteUrl) {
|
||||
const remoteName = `paseo-pr-${changeRequestNumber}`;
|
||||
remotePlan.pushRemote = {
|
||||
name: remoteName,
|
||||
url: source.pushRemoteUrl,
|
||||
headRef: source.headRef,
|
||||
track: true,
|
||||
};
|
||||
} else if (shouldTrackOriginHead && localBranchName !== source.headRef) {
|
||||
const originUrl = await getWorktreeRemotePushUrl(cwd, "origin");
|
||||
if (originUrl) {
|
||||
remotePlan.pushRemote = {
|
||||
name: `paseo-pr-${changeRequestNumber}`,
|
||||
url: originUrl,
|
||||
headRef: source.headRef,
|
||||
track: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (trackingRemote) {
|
||||
remotePlan.trackingRemote = trackingRemote;
|
||||
}
|
||||
case "checkout-github-pr":
|
||||
return resolveChangeRequestWorktreeSourcePlan({ cwd, source });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
branchName: localBranchName,
|
||||
metadataBaseRefName: normalizedBaseRefName,
|
||||
changeRequestLookupTarget: {
|
||||
headRef: source.headRef,
|
||||
...(source.headRepositoryOwner
|
||||
? { headRepositoryOwner: source.headRepositoryOwner }
|
||||
: {}),
|
||||
changeRequestNumber,
|
||||
},
|
||||
addArguments: [localBranchName],
|
||||
...remotePlan,
|
||||
};
|
||||
async function resolveCheckoutBranchWorktreeSourcePlan(options: {
|
||||
branchName: string;
|
||||
cwd: string;
|
||||
}): Promise<WorktreeSourcePlan> {
|
||||
await validateExistingWorktreeBranchName(options.cwd, options.branchName);
|
||||
const needsFetch = !(await localBranchExists(options.cwd, options.branchName));
|
||||
let createdBranchOid: string | undefined;
|
||||
if (needsFetch) {
|
||||
try {
|
||||
createdBranchOid = await fetchNewLocalBranchAtomically({
|
||||
cwd: options.cwd,
|
||||
localBranchName: options.branchName,
|
||||
remoteName: "origin",
|
||||
remoteRef: `refs/heads/${options.branchName}`,
|
||||
});
|
||||
} catch {
|
||||
throw new UnknownBranchError({ branchName: options.branchName, cwd: options.cwd });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (await isBranchCheckedOut(options.cwd, options.branchName)) {
|
||||
throw new BranchAlreadyCheckedOutError(options.branchName);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!needsFetch) {
|
||||
throw error;
|
||||
}
|
||||
return rollbackCreatedWorktreeBranch(
|
||||
{
|
||||
cwd: options.cwd,
|
||||
createdBranchName: options.branchName,
|
||||
expectedOid: createdBranchOid,
|
||||
},
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
branchName: options.branchName,
|
||||
...(needsFetch
|
||||
? {
|
||||
createdBranchName: options.branchName,
|
||||
createdBranchNameBeforeWorktreeAdd: options.branchName,
|
||||
createdBranchOidBeforeWorktreeAdd: createdBranchOid,
|
||||
}
|
||||
: {}),
|
||||
metadataBaseRefName: options.branchName,
|
||||
addArguments: [options.branchName],
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveChangeRequestWorktreeSourcePlan(options: {
|
||||
cwd: string;
|
||||
source: ChangeRequestWorktreeSource;
|
||||
}): Promise<WorktreeSourcePlan> {
|
||||
const { cwd, source } = options;
|
||||
const localBranchCandidate = source.localBranchName ?? source.headRef;
|
||||
await validateExistingWorktreeBranchName(cwd, localBranchCandidate);
|
||||
const localBranchName = await resolveUniqueLocalBranchName(cwd, localBranchCandidate);
|
||||
const normalizedBaseRefName = normalizeRequiredBaseBranch(source.baseRefName);
|
||||
const changeRequestNumber =
|
||||
source.kind === "checkout-github-pr" ? source.githubPrNumber : source.changeRequestNumber;
|
||||
|
||||
let createdBranchOid: string | undefined;
|
||||
try {
|
||||
createdBranchOid = await fetchWorktreeCheckoutRefs({
|
||||
cwd,
|
||||
localBranchName,
|
||||
checkoutRefs: source.checkoutRefs ?? [
|
||||
{ remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` },
|
||||
],
|
||||
});
|
||||
const shouldTrackOriginHead = source.trackOriginHead === true;
|
||||
const trackingRemote = shouldTrackOriginHead
|
||||
? await tryFetchWorktreeTrackingRemote({
|
||||
cwd,
|
||||
remoteName: "origin",
|
||||
headRef: source.headRef,
|
||||
})
|
||||
: undefined;
|
||||
const remotePlan: Pick<WorktreeSourcePlan, "pushRemote" | "trackingRemote"> = {};
|
||||
if (source.pushRemoteUrl) {
|
||||
const remoteName = `paseo-pr-${changeRequestNumber}`;
|
||||
remotePlan.pushRemote = {
|
||||
name: remoteName,
|
||||
url: source.pushRemoteUrl,
|
||||
headRef: source.headRef,
|
||||
track: true,
|
||||
};
|
||||
} else if (shouldTrackOriginHead && localBranchName !== source.headRef) {
|
||||
const originUrl = await getWorktreeRemotePushUrl(cwd, "origin");
|
||||
if (originUrl) {
|
||||
remotePlan.pushRemote = {
|
||||
name: `paseo-pr-${changeRequestNumber}`,
|
||||
url: originUrl,
|
||||
headRef: source.headRef,
|
||||
track: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (trackingRemote) {
|
||||
remotePlan.trackingRemote = trackingRemote;
|
||||
}
|
||||
|
||||
return {
|
||||
branchName: localBranchName,
|
||||
createdBranchName: localBranchName,
|
||||
createdBranchNameBeforeWorktreeAdd: localBranchName,
|
||||
createdBranchOidBeforeWorktreeAdd: createdBranchOid,
|
||||
metadataBaseRefName: normalizedBaseRefName,
|
||||
changeRequestLookupTarget: {
|
||||
headRef: source.headRef,
|
||||
...(source.headRepositoryOwner ? { headRepositoryOwner: source.headRepositoryOwner } : {}),
|
||||
changeRequestNumber,
|
||||
},
|
||||
addArguments: [localBranchName],
|
||||
...remotePlan,
|
||||
};
|
||||
} catch (error) {
|
||||
return rollbackCreatedWorktreeBranch(
|
||||
{ cwd, createdBranchName: localBranchName, expectedOid: createdBranchOid },
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function configureWorktreePushRemote(options: {
|
||||
@@ -1471,27 +1643,22 @@ async function fetchWorktreeCheckoutRefs(options: {
|
||||
cwd: string;
|
||||
localBranchName: string;
|
||||
checkoutRefs: WorktreeCheckoutRef[];
|
||||
}): Promise<void> {
|
||||
}): Promise<string> {
|
||||
let lastResult:
|
||||
| Awaited<ReturnType<typeof runGitCommand>>
|
||||
| { stderr: string; stdout: string; exitCode: number | null }
|
||||
| null = null;
|
||||
for (const checkoutRef of options.checkoutRefs) {
|
||||
lastResult = await runGitCommand(
|
||||
[
|
||||
"fetch",
|
||||
checkoutRef.remoteName ?? "origin",
|
||||
`+${checkoutRef.remoteRef}:refs/heads/${options.localBranchName}`,
|
||||
"--force",
|
||||
],
|
||||
{
|
||||
try {
|
||||
return await fetchNewLocalBranchAtomically({
|
||||
cwd: options.cwd,
|
||||
timeout: 120_000,
|
||||
acceptExitCodes: [0, 1, 128],
|
||||
},
|
||||
);
|
||||
if (lastResult.exitCode === 0) {
|
||||
return;
|
||||
localBranchName: options.localBranchName,
|
||||
remoteName: checkoutRef.remoteName ?? "origin",
|
||||
remoteRef: checkoutRef.remoteRef,
|
||||
});
|
||||
} catch (error) {
|
||||
lastResult =
|
||||
error instanceof Error ? { stderr: error.message, stdout: "", exitCode: 1 } : null;
|
||||
}
|
||||
}
|
||||
const attemptedRefs = options.checkoutRefs
|
||||
@@ -1502,6 +1669,35 @@ async function fetchWorktreeCheckoutRefs(options: {
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchNewLocalBranchAtomically(options: {
|
||||
cwd: string;
|
||||
localBranchName: string;
|
||||
remoteName: string;
|
||||
remoteRef: string;
|
||||
}): Promise<string> {
|
||||
const temporaryRef = `refs/paseo/worktree-fetch/${randomUUID()}`;
|
||||
try {
|
||||
await runGitCommand(
|
||||
["fetch", options.remoteName, `${options.remoteRef}:${temporaryRef}`, "--force"],
|
||||
{ cwd: options.cwd, timeout: 120_000 },
|
||||
);
|
||||
const { stdout } = await runGitCommand(["rev-parse", "--verify", temporaryRef], {
|
||||
cwd: options.cwd,
|
||||
});
|
||||
const oid = stdout.trim();
|
||||
const nullOid = "0".repeat(oid.length);
|
||||
await runGitCommand(["update-ref", `refs/heads/${options.localBranchName}`, oid, nullOid], {
|
||||
cwd: options.cwd,
|
||||
});
|
||||
return oid;
|
||||
} finally {
|
||||
await runGitCommand(["update-ref", "-d", temporaryRef], {
|
||||
cwd: options.cwd,
|
||||
acceptExitCodes: [0, 1, 128],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function tryFetchWorktreeTrackingRemote(options: {
|
||||
cwd: string;
|
||||
remoteName: string;
|
||||
|
||||
@@ -110,6 +110,46 @@ Both fields accept a multiline shell script or an array of commands; commands ru
|
||||
|
||||
Commands run with the worktree as `cwd`. Use `$PASEO_SOURCE_CHECKOUT_PATH` to reach files in the original checkout (untracked config, local caches, etc).
|
||||
|
||||
## .worktreeinclude
|
||||
|
||||
Use a root-level .worktreeinclude to materialize local source-checkout files before
|
||||
worktree.setup runs. Each path is relative to the source checkout.
|
||||
|
||||
# Copy is the default.
|
||||
.env.local
|
||||
.cache/**
|
||||
|
||||
# Modes can also be explicit.
|
||||
symlink node_modules
|
||||
copy .tool-state/**
|
||||
|
||||
Each line is `[copy|symlink] <path>`; the mode is optional and defaults to `copy`. Blank lines
|
||||
and whole-line comments are ignored. A single star matches within a path segment and a double
|
||||
star matches recursively; a directory ending in /\*\* materializes that directory as one
|
||||
recursive entry. Absolute paths, parent-directory paths, and .git paths are rejected.
|
||||
|
||||
Copy entries are independent snapshots: a copied file or directory replaces an existing path on
|
||||
a later materialization. Symlink entries point directly at
|
||||
the live source file or directory, so changes through either path affect the same data. Paseo
|
||||
does not replace an existing file, directory, or different link with a symlink.
|
||||
|
||||
Entries must resolve to regular files or directories. A top-level source symbolic link is
|
||||
dereferenced only when its canonical target remains inside the source checkout: `copy` snapshots
|
||||
that target and `symlink` links directly to it. Directory snapshots reject nested symbolic links
|
||||
so Paseo never writes through an unexpected path. A symlinked directory intentionally exposes its
|
||||
live source contents.
|
||||
|
||||
Prefer paths ignored by the target branch. For a symlinked directory, use an ignore rule without
|
||||
a trailing slash (for example, node_modules, not node_modules/), because Git treats the link
|
||||
itself as a file. Unignored materialized paths appear in git status.
|
||||
|
||||
On Windows, Paseo uses junctions for local directories. File links and network-directory links
|
||||
require Windows symbolic-link support. It never silently copies an explicit `symlink <path>`
|
||||
entry; enable Developer Mode or switch that entry to `copy <path>` if link creation fails.
|
||||
|
||||
Archiving removes only the worktree's links, not their source targets. If the source path is
|
||||
later moved or deleted, a symlink becomes broken; Paseo does not repair it automatically.
|
||||
|
||||
## Scripts and services
|
||||
|
||||
`scripts` are named commands you can run inside a worktree on demand. Mark one as a _service_ and Paseo supervises it as a long-running process, assigns it a port, and routes HTTP traffic to it through the daemon's reverse proxy.
|
||||
|
||||
Reference in New Issue
Block a user