mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 11:42:52 +00:00
Compare 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.
|
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.
|
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
|
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.
|
- **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.
|
- **`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** 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.
|
- **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:
|
Override knobs:
|
||||||
|
|||||||
@@ -315,6 +315,7 @@ configureLinuxSandbox({
|
|||||||
resourcesPath: process.resourcesPath,
|
resourcesPath: process.resourcesPath,
|
||||||
statSandbox: (sandboxPath) => statSync(sandboxPath),
|
statSandbox: (sandboxPath) => statSync(sandboxPath),
|
||||||
disableSandbox: () => app.commandLine.appendSwitch("no-sandbox"),
|
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),
|
reportInspectionError: (error) => log.error("[linux-sandbox] failed to inspect helper", error),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ function configureWithSandbox(
|
|||||||
) {
|
) {
|
||||||
const disabledSwitches: string[] = [];
|
const disabledSwitches: string[] = [];
|
||||||
const inspectionErrors: unknown[] = [];
|
const inspectionErrors: unknown[] = [];
|
||||||
|
const disableReports: unknown[] = [];
|
||||||
|
|
||||||
configureLinuxSandbox({
|
configureLinuxSandbox({
|
||||||
platform,
|
platform,
|
||||||
@@ -23,10 +24,11 @@ function configureWithSandbox(
|
|||||||
return sandbox;
|
return sandbox;
|
||||||
},
|
},
|
||||||
disableSandbox: () => disabledSwitches.push("no-sandbox"),
|
disableSandbox: () => disabledSwitches.push("no-sandbox"),
|
||||||
|
reportSandboxDisabled: (report) => disableReports.push(report),
|
||||||
reportInspectionError: (error) => inspectionErrors.push(error),
|
reportInspectionError: (error) => inspectionErrors.push(error),
|
||||||
});
|
});
|
||||||
|
|
||||||
return { disabledSwitches, inspectionErrors };
|
return { disabledSwitches, disableReports, inspectionErrors };
|
||||||
}
|
}
|
||||||
|
|
||||||
function createFileSystemError(code: string): NodeJS.ErrnoException {
|
function createFileSystemError(code: string): NodeJS.ErrnoException {
|
||||||
@@ -39,6 +41,14 @@ describe("configureLinuxSandbox", () => {
|
|||||||
it("disables the sandbox when an AppImage mount strips SUID", () => {
|
it("disables the sandbox when an AppImage mount strips SUID", () => {
|
||||||
expect(configureWithSandbox({ uid: 1000, mode: 0o755 })).toEqual({
|
expect(configureWithSandbox({ uid: 1000, mode: 0o755 })).toEqual({
|
||||||
disabledSwitches: ["no-sandbox"],
|
disabledSwitches: ["no-sandbox"],
|
||||||
|
disableReports: [
|
||||||
|
{
|
||||||
|
reason: "wrong-owner-or-mode",
|
||||||
|
sandboxPath: "/opt/Paseo/chrome-sandbox",
|
||||||
|
observedUid: 1000,
|
||||||
|
observedMode: "0755",
|
||||||
|
},
|
||||||
|
],
|
||||||
inspectionErrors: [],
|
inspectionErrors: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -46,6 +56,7 @@ describe("configureLinuxSandbox", () => {
|
|||||||
it("keeps the sandbox for a root-owned 4755 helper", () => {
|
it("keeps the sandbox for a root-owned 4755 helper", () => {
|
||||||
expect(configureWithSandbox({ uid: 0, mode: 0o4755 })).toEqual({
|
expect(configureWithSandbox({ uid: 0, mode: 0o4755 })).toEqual({
|
||||||
disabledSwitches: [],
|
disabledSwitches: [],
|
||||||
|
disableReports: [],
|
||||||
inspectionErrors: [],
|
inspectionErrors: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -53,6 +64,14 @@ describe("configureLinuxSandbox", () => {
|
|||||||
it("disables the sandbox when a SUID helper is not root-owned", () => {
|
it("disables the sandbox when a SUID helper is not root-owned", () => {
|
||||||
expect(configureWithSandbox({ uid: 1000, mode: 0o4755 })).toEqual({
|
expect(configureWithSandbox({ uid: 1000, mode: 0o4755 })).toEqual({
|
||||||
disabledSwitches: ["no-sandbox"],
|
disabledSwitches: ["no-sandbox"],
|
||||||
|
disableReports: [
|
||||||
|
{
|
||||||
|
reason: "wrong-owner-or-mode",
|
||||||
|
sandboxPath: "/opt/Paseo/chrome-sandbox",
|
||||||
|
observedUid: 1000,
|
||||||
|
observedMode: "4755",
|
||||||
|
},
|
||||||
|
],
|
||||||
inspectionErrors: [],
|
inspectionErrors: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -60,6 +79,12 @@ describe("configureLinuxSandbox", () => {
|
|||||||
it("disables the sandbox when the helper is missing", () => {
|
it("disables the sandbox when the helper is missing", () => {
|
||||||
expect(configureWithSandbox(createFileSystemError("ENOENT"))).toEqual({
|
expect(configureWithSandbox(createFileSystemError("ENOENT"))).toEqual({
|
||||||
disabledSwitches: ["no-sandbox"],
|
disabledSwitches: ["no-sandbox"],
|
||||||
|
disableReports: [
|
||||||
|
{
|
||||||
|
reason: "helper-missing",
|
||||||
|
sandboxPath: "/opt/Paseo/chrome-sandbox",
|
||||||
|
},
|
||||||
|
],
|
||||||
inspectionErrors: [],
|
inspectionErrors: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -69,6 +94,7 @@ describe("configureLinuxSandbox", () => {
|
|||||||
|
|
||||||
expect(configureWithSandbox(permissionError)).toEqual({
|
expect(configureWithSandbox(permissionError)).toEqual({
|
||||||
disabledSwitches: [],
|
disabledSwitches: [],
|
||||||
|
disableReports: [],
|
||||||
inspectionErrors: [permissionError],
|
inspectionErrors: [permissionError],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -76,6 +102,7 @@ describe("configureLinuxSandbox", () => {
|
|||||||
it("does not inspect or configure the sandbox outside Linux", () => {
|
it("does not inspect or configure the sandbox outside Linux", () => {
|
||||||
expect(configureWithSandbox(createFileSystemError("EACCES"), "darwin")).toEqual({
|
expect(configureWithSandbox(createFileSystemError("EACCES"), "darwin")).toEqual({
|
||||||
disabledSwitches: [],
|
disabledSwitches: [],
|
||||||
|
disableReports: [],
|
||||||
inspectionErrors: [],
|
inspectionErrors: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,11 +8,24 @@ interface SandboxMetadata {
|
|||||||
uid: number;
|
uid: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SandboxDisableReport =
|
||||||
|
| {
|
||||||
|
reason: "helper-missing";
|
||||||
|
sandboxPath: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
reason: "wrong-owner-or-mode";
|
||||||
|
sandboxPath: string;
|
||||||
|
observedUid: number;
|
||||||
|
observedMode: string;
|
||||||
|
};
|
||||||
|
|
||||||
interface LinuxSandboxConfiguration {
|
interface LinuxSandboxConfiguration {
|
||||||
platform: NodeJS.Platform;
|
platform: NodeJS.Platform;
|
||||||
resourcesPath: string;
|
resourcesPath: string;
|
||||||
statSandbox: (sandboxPath: string) => SandboxMetadata;
|
statSandbox: (sandboxPath: string) => SandboxMetadata;
|
||||||
disableSandbox: () => void;
|
disableSandbox: () => void;
|
||||||
|
reportSandboxDisabled: (report: SandboxDisableReport) => void;
|
||||||
reportInspectionError: (error: unknown) => void;
|
reportInspectionError: (error: unknown) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,17 +38,25 @@ export function configureLinuxSandbox(input: LinuxSandboxConfiguration): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sandboxPath = path.join(input.resourcesPath, "..", "chrome-sandbox");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sandboxPath = path.join(input.resourcesPath, "..", "chrome-sandbox");
|
|
||||||
const sandbox = input.statSandbox(sandboxPath);
|
const sandbox = input.statSandbox(sandboxPath);
|
||||||
const hasUsableSandbox =
|
const hasUsableSandbox =
|
||||||
sandbox.uid === 0 && (sandbox.mode & PERMISSION_BITS) === REQUIRED_SANDBOX_MODE;
|
sandbox.uid === 0 && (sandbox.mode & PERMISSION_BITS) === REQUIRED_SANDBOX_MODE;
|
||||||
|
|
||||||
if (!hasUsableSandbox) {
|
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();
|
input.disableSandbox();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isMissingSandbox(error)) {
|
if (isMissingSandbox(error)) {
|
||||||
|
input.reportSandboxDisabled({ reason: "helper-missing", sandboxPath });
|
||||||
input.disableSandbox();
|
input.disableSandbox();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import type { WorktreeCreationIntent } from "./resolve-worktree-creation-intent.
|
|||||||
import { resolveFirstAgentPromptTitle } from "./agent/create-agent-title.js";
|
import { resolveFirstAgentPromptTitle } from "./agent/create-agent-title.js";
|
||||||
import { buildAgentBranchNameSeed } from "./agent/prompt-attachments.js";
|
import { buildAgentBranchNameSeed } from "./agent/prompt-attachments.js";
|
||||||
import type { FirstAgentContext } from "@getpaseo/protocol/messages";
|
import type { FirstAgentContext } from "@getpaseo/protocol/messages";
|
||||||
|
import type { WorktreeIncludeSummary } from "../utils/worktree-include.js";
|
||||||
|
|
||||||
export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {
|
export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
@@ -36,6 +37,7 @@ export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {
|
|||||||
|
|
||||||
export interface CreatePaseoWorktreeResult {
|
export interface CreatePaseoWorktreeResult {
|
||||||
worktree: WorktreeConfig;
|
worktree: WorktreeConfig;
|
||||||
|
worktreeIncludeSummary?: WorktreeIncludeSummary;
|
||||||
intent: WorktreeCreationIntent;
|
intent: WorktreeCreationIntent;
|
||||||
workspace: PersistedWorkspaceRecord;
|
workspace: PersistedWorkspaceRecord;
|
||||||
repoRoot: string;
|
repoRoot: string;
|
||||||
@@ -98,6 +100,7 @@ export async function createPaseoWorktree(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
worktree: createdWorktree.worktree,
|
worktree: createdWorktree.worktree,
|
||||||
|
worktreeIncludeSummary: createdWorktree.worktreeIncludeSummary,
|
||||||
intent: createdWorktree.intent,
|
intent: createdWorktree.intent,
|
||||||
workspace,
|
workspace,
|
||||||
repoRoot: createdWorktree.repoRoot,
|
repoRoot: createdWorktree.repoRoot,
|
||||||
|
|||||||
@@ -746,6 +746,7 @@ export class Session {
|
|||||||
logger: this.sessionLogger,
|
logger: this.sessionLogger,
|
||||||
});
|
});
|
||||||
this.workspaceRecovery = createWorkspaceRecoveryService({
|
this.workspaceRecovery = createWorkspaceRecoveryService({
|
||||||
|
logger: this.sessionLogger,
|
||||||
paseoHome: this.paseoHome,
|
paseoHome: this.paseoHome,
|
||||||
worktreesRoot: this.worktreesRoot,
|
worktreesRoot: this.worktreesRoot,
|
||||||
getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId),
|
getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId),
|
||||||
|
|||||||
+14
@@ -76,6 +76,7 @@ function createHarness(input?: {
|
|||||||
const directories = new Set(input?.directories ?? ["/repo"]);
|
const directories = new Set(input?.directories ?? ["/repo"]);
|
||||||
const unarchived: string[] = [];
|
const unarchived: string[] = [];
|
||||||
const service = createWorkspaceRecoveryService({
|
const service = createWorkspaceRecoveryService({
|
||||||
|
logger: { warn: () => undefined } as never,
|
||||||
paseoHome: input?.paseoHome ?? "/paseo-home",
|
paseoHome: input?.paseoHome ?? "/paseo-home",
|
||||||
worktreesRoot: input?.worktreesRoot ?? "/worktrees",
|
worktreesRoot: input?.worktreesRoot ?? "/worktrees",
|
||||||
getWorkspace: async (workspaceId) =>
|
getWorkspace: async (workspaceId) =>
|
||||||
@@ -135,6 +136,7 @@ describe("workspace recovery", () => {
|
|||||||
const sourceSubdirectory = join(repoDir, "packages", "app");
|
const sourceSubdirectory = join(repoDir, "packages", "app");
|
||||||
mkdirSync(sourceSubdirectory, { recursive: true });
|
mkdirSync(sourceSubdirectory, { recursive: true });
|
||||||
writeFileSync(join(sourceSubdirectory, "README.md"), "app\n");
|
writeFileSync(join(sourceSubdirectory, "README.md"), "app\n");
|
||||||
|
writeFileSync(join(repoDir, ".worktreeinclude"), "missing.local\n");
|
||||||
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
|
||||||
execFileSync("git", ["commit", "-m", "add app"], { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["commit", "-m", "add app"], { cwd: repoDir, stdio: "pipe" });
|
||||||
execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" });
|
execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" });
|
||||||
@@ -170,7 +172,9 @@ describe("workspace recovery", () => {
|
|||||||
mainRepoRoot: repoDir,
|
mainRepoRoot: repoDir,
|
||||||
});
|
});
|
||||||
const unarchived: string[] = [];
|
const unarchived: string[] = [];
|
||||||
|
const warnings: unknown[][] = [];
|
||||||
const service = createWorkspaceRecoveryService({
|
const service = createWorkspaceRecoveryService({
|
||||||
|
logger: { warn: (...args: unknown[]) => warnings.push(args) } as never,
|
||||||
paseoHome,
|
paseoHome,
|
||||||
worktreesRoot,
|
worktreesRoot,
|
||||||
getWorkspace: async (workspaceId) =>
|
getWorkspace: async (workspaceId) =>
|
||||||
@@ -189,6 +193,15 @@ describe("workspace recovery", () => {
|
|||||||
expect(existsSync(worktreeRoot)).toBe(true);
|
expect(existsSync(worktreeRoot)).toBe(true);
|
||||||
expect(existsSync(workspaceCwd)).toBe(true);
|
expect(existsSync(workspaceCwd)).toBe(true);
|
||||||
expect(unarchived).toEqual([workspace.workspaceId]);
|
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 () => {
|
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 unarchived: string[] = [];
|
||||||
const service = createWorkspaceRecoveryService({
|
const service = createWorkspaceRecoveryService({
|
||||||
|
logger: { warn: () => undefined } as never,
|
||||||
paseoHome,
|
paseoHome,
|
||||||
worktreesRoot,
|
worktreesRoot,
|
||||||
getWorkspace: async (workspaceId) =>
|
getWorkspace: async (workspaceId) =>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { basename } from "node:path";
|
import { basename } from "node:path";
|
||||||
|
import type { Logger } from "pino";
|
||||||
|
|
||||||
import { createRealpathAwarePathMatcher } from "../../../utils/path.js";
|
import { createRealpathAwarePathMatcher } from "../../../utils/path.js";
|
||||||
import { runGitCommand } from "../../../utils/run-git-command.js";
|
import { runGitCommand } from "../../../utils/run-git-command.js";
|
||||||
@@ -59,6 +60,7 @@ type RecoveryPlan =
|
|||||||
type UnavailableRecoveryState = Extract<WorkspaceRecoveryState, { kind: "unavailable" }>;
|
type UnavailableRecoveryState = Extract<WorkspaceRecoveryState, { kind: "unavailable" }>;
|
||||||
|
|
||||||
export function createWorkspaceRecoveryService(deps: {
|
export function createWorkspaceRecoveryService(deps: {
|
||||||
|
logger: Logger;
|
||||||
paseoHome: string;
|
paseoHome: string;
|
||||||
worktreesRoot?: string;
|
worktreesRoot?: string;
|
||||||
getWorkspace: (workspaceId: string) => Promise<PersistedWorkspaceRecord | null>;
|
getWorkspace: (workspaceId: string) => Promise<PersistedWorkspaceRecord | null>;
|
||||||
@@ -195,6 +197,16 @@ export function createWorkspaceRecoveryService(deps: {
|
|||||||
worktreesRoot: deps.worktreesRoot,
|
worktreesRoot: deps.worktreesRoot,
|
||||||
});
|
});
|
||||||
recreatedWorktreePath = result.worktreePath;
|
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) {
|
} catch (error) {
|
||||||
throw toWorktreeRequestError(error);
|
throw toWorktreeRequestError(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
validateBranchSlug,
|
validateBranchSlug,
|
||||||
type WorktreeConfig,
|
type WorktreeConfig,
|
||||||
} from "../utils/worktree.js";
|
} from "../utils/worktree.js";
|
||||||
|
import type { WorktreeIncludeSummary } from "../utils/worktree-include.js";
|
||||||
import {
|
import {
|
||||||
resolveWorktreeCreationIntent,
|
resolveWorktreeCreationIntent,
|
||||||
type ResolveWorktreeCreationIntentInput,
|
type ResolveWorktreeCreationIntentInput,
|
||||||
@@ -42,6 +43,7 @@ export interface CreateWorktreeCoreDeps {
|
|||||||
|
|
||||||
export interface CreateWorktreeCoreResult {
|
export interface CreateWorktreeCoreResult {
|
||||||
worktree: WorktreeConfig;
|
worktree: WorktreeConfig;
|
||||||
|
worktreeIncludeSummary?: WorktreeIncludeSummary;
|
||||||
intent: WorktreeCreationIntent;
|
intent: WorktreeCreationIntent;
|
||||||
repoRoot: string;
|
repoRoot: string;
|
||||||
created: boolean;
|
created: boolean;
|
||||||
@@ -120,15 +122,17 @@ export async function createWorktreeCore(
|
|||||||
return { worktree: existingWorktree, intent, repoRoot, created: false };
|
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 {
|
return {
|
||||||
worktree: await createWorktree({
|
worktree,
|
||||||
cwd: repoRoot,
|
worktreeIncludeSummary,
|
||||||
worktreeSlug: normalizedSlug,
|
|
||||||
source: intent,
|
|
||||||
runSetup: input.runSetup ?? true,
|
|
||||||
paseoHome: input.paseoHome,
|
|
||||||
worktreesRoot: input.worktreesRoot,
|
|
||||||
}),
|
|
||||||
intent,
|
intent,
|
||||||
repoRoot,
|
repoRoot,
|
||||||
created: true,
|
created: true,
|
||||||
|
|||||||
@@ -605,6 +605,17 @@ export async function createPaseoWorktreeWorkflow(
|
|||||||
const workspace = createdWorktree.workspace;
|
const workspace = createdWorktree.workspace;
|
||||||
const setupContinuation = options?.setupContinuation ?? { kind: "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(() => {
|
setTimeout(() => {
|
||||||
if (input.firstAgentContext) {
|
if (input.firstAgentContext) {
|
||||||
dependencies.autoNameWorkspaceBranchForFirstAgent({
|
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,
|
writeFileSync,
|
||||||
readFileSync,
|
readFileSync,
|
||||||
chmodSync,
|
chmodSync,
|
||||||
|
lstatSync,
|
||||||
} from "fs";
|
} from "fs";
|
||||||
import { delimiter, dirname, join } from "path";
|
import { delimiter, dirname, join } from "path";
|
||||||
import { tmpdir } from "os";
|
import { tmpdir } from "os";
|
||||||
@@ -362,6 +363,68 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
|||||||
expect(metadata).toMatchObject({ baseRefName: "main" });
|
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 () => {
|
it("fetches a GitHub PR branch when the head ref contains uppercase letters and dots", async () => {
|
||||||
const remoteDir = join(tempDir, "remote.git");
|
const remoteDir = join(tempDir, "remote.git");
|
||||||
const remoteCloneDir = join(tempDir, "remote-clone");
|
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 () => {
|
it("creates a worktree without error when no paseo.json exists in the main repo", async () => {
|
||||||
const result = await createLegacyWorktreeForTest({
|
const result = await createLegacyWorktreeForTest({
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { existsSync, mkdirSync, realpathSync, rmSync, statSync } from "fs";
|
|||||||
import { copyFile, rm, stat } from "fs/promises";
|
import { copyFile, rm, stat } from "fs/promises";
|
||||||
import { join, basename, dirname, isAbsolute, resolve, sep } from "path";
|
import { join, basename, dirname, isAbsolute, resolve, sep } from "path";
|
||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
import stripAnsi from "strip-ansi";
|
import stripAnsi from "strip-ansi";
|
||||||
import {
|
import {
|
||||||
buildStringCommandShellInvocation,
|
buildStringCommandShellInvocation,
|
||||||
@@ -36,6 +36,11 @@ import { createExternalProcessEnv } from "../server/paseo-env.js";
|
|||||||
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
|
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
|
||||||
import { validateBranchSlug } from "@getpaseo/protocol/branch-slug";
|
import { validateBranchSlug } from "@getpaseo/protocol/branch-slug";
|
||||||
import { expandTilde, getRealpathAwareRelativePath, isPathInsideRoot } from "./path.js";
|
import { expandTilde, getRealpathAwareRelativePath, isPathInsideRoot } from "./path.js";
|
||||||
|
import {
|
||||||
|
materializeWorktreeIncludePlan,
|
||||||
|
readWorktreeIncludePlan,
|
||||||
|
type WorktreeIncludeSummary,
|
||||||
|
} from "./worktree-include.js";
|
||||||
|
|
||||||
export { slugify, validateBranchSlug } from "@getpaseo/protocol/branch-slug";
|
export { slugify, validateBranchSlug } from "@getpaseo/protocol/branch-slug";
|
||||||
|
|
||||||
@@ -49,6 +54,10 @@ export interface WorktreeConfig {
|
|||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreatedWorktreeConfig extends WorktreeConfig {
|
||||||
|
worktreeIncludeSummary: WorktreeIncludeSummary;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorktreeRuntimeEnv {
|
export interface WorktreeRuntimeEnv {
|
||||||
[key: string]: string;
|
[key: string]: string;
|
||||||
PASEO_SOURCE_CHECKOUT_PATH: 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(
|
export async function rollbackCreatedPaseoWorktree(
|
||||||
options: DeletePaseoWorktreeOptions,
|
options: RollbackCreatedPaseoWorktreeOptions,
|
||||||
cause: unknown,
|
cause: unknown,
|
||||||
): Promise<never> {
|
): Promise<never> {
|
||||||
let cleanupError: unknown;
|
let cleanupError: unknown;
|
||||||
try {
|
try {
|
||||||
await deletePaseoWorktree(options);
|
await deletePaseoWorktree(options);
|
||||||
|
await removeCreatedWorktreeBranch(options);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
cleanupError = error;
|
cleanupError = error;
|
||||||
}
|
}
|
||||||
@@ -1233,50 +1296,100 @@ export const createWorktree = async ({
|
|||||||
runSetup,
|
runSetup,
|
||||||
paseoHome,
|
paseoHome,
|
||||||
worktreesRoot,
|
worktreesRoot,
|
||||||
}: CreateWorktreeOptions): Promise<WorktreeConfig> => {
|
}: CreateWorktreeOptions): Promise<CreatedWorktreeConfig> => {
|
||||||
const sourcePlan = await resolveWorktreeSourcePlan({ cwd, source, desiredSlug: worktreeSlug });
|
const sourcePlan = await resolveWorktreeSourcePlan({ cwd, source, desiredSlug: worktreeSlug });
|
||||||
let worktreePath = join(await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot), worktreeSlug);
|
const { worktreeIncludePlan, worktreePath } = await (async () => {
|
||||||
mkdirSync(dirname(worktreePath), { recursive: true });
|
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
|
// Also handle worktree path collision
|
||||||
let finalWorktreePath = worktreePath;
|
let finalWorktreePath = requestedWorktreePath;
|
||||||
let pathSuffix = 1;
|
let pathSuffix = 1;
|
||||||
while (existsSync(finalWorktreePath)) {
|
while (existsSync(finalWorktreePath)) {
|
||||||
finalWorktreePath = `${worktreePath}-${pathSuffix}`;
|
finalWorktreePath = `${requestedWorktreePath}-${pathSuffix}`;
|
||||||
pathSuffix++;
|
pathSuffix++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Primitive owner for `git worktree add`; callers route through createWorktreeCore.
|
// Primitive owner for `git worktree add`; callers route through createWorktreeCore.
|
||||||
await runGitCommand(["worktree", "add", finalWorktreePath, ...sourcePlan.addArguments], {
|
await runGitCommand(["worktree", "add", finalWorktreePath, ...sourcePlan.addArguments], {
|
||||||
cwd,
|
cwd,
|
||||||
timeout: 120_000,
|
timeout: 120_000,
|
||||||
});
|
});
|
||||||
worktreePath = normalizePathForOwnership(finalWorktreePath);
|
|
||||||
|
|
||||||
if (sourcePlan.pushRemote) {
|
return {
|
||||||
await configureWorktreePushRemote({
|
worktreeIncludePlan: includePlan,
|
||||||
cwd,
|
worktreePath: normalizePathForOwnership(finalWorktreePath),
|
||||||
branchName: sourcePlan.branchName,
|
};
|
||||||
remote: sourcePlan.pushRemote,
|
} 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 seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath });
|
||||||
await configureWorktreeTrackingRemote({
|
const materialization = await materializeWorktreeIncludePlan({
|
||||||
cwd,
|
plan: worktreeIncludePlan,
|
||||||
branchName: sourcePlan.branchName,
|
worktreeRoot: worktreePath,
|
||||||
remote: sourcePlan.trackingRemote,
|
|
||||||
});
|
});
|
||||||
|
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) {
|
if (runSetup) {
|
||||||
await runWorktreeSetupCommands({
|
await runWorktreeSetupCommands({
|
||||||
worktreePath,
|
worktreePath,
|
||||||
@@ -1287,6 +1400,7 @@ export const createWorktree = async ({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
branchName: sourcePlan.branchName,
|
branchName: sourcePlan.branchName,
|
||||||
|
worktreeIncludeSummary,
|
||||||
worktreePath,
|
worktreePath,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -1299,6 +1413,9 @@ interface ResolveWorktreeSourcePlanOptions {
|
|||||||
|
|
||||||
interface WorktreeSourcePlan {
|
interface WorktreeSourcePlan {
|
||||||
branchName: string;
|
branchName: string;
|
||||||
|
createdBranchName?: string;
|
||||||
|
createdBranchNameBeforeWorktreeAdd?: string;
|
||||||
|
createdBranchOidBeforeWorktreeAdd?: string;
|
||||||
metadataBaseRefName: string;
|
metadataBaseRefName: string;
|
||||||
changeRequestLookupTarget?: PaseoWorktreeChangeRequestLookupTarget;
|
changeRequestLookupTarget?: PaseoWorktreeChangeRequestLookupTarget;
|
||||||
addArguments: string[];
|
addArguments: string[];
|
||||||
@@ -1314,6 +1431,11 @@ interface WorktreeSourcePlan {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ChangeRequestWorktreeSource = Extract<
|
||||||
|
WorktreeSource,
|
||||||
|
{ kind: "checkout-change-request" | "checkout-github-pr" }
|
||||||
|
>;
|
||||||
|
|
||||||
async function resolveWorktreeSourcePlan({
|
async function resolveWorktreeSourcePlan({
|
||||||
cwd,
|
cwd,
|
||||||
source,
|
source,
|
||||||
@@ -1332,94 +1454,144 @@ async function resolveWorktreeSourcePlan({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
branchName: newBranchName,
|
branchName: newBranchName,
|
||||||
|
createdBranchName: newBranchName,
|
||||||
metadataBaseRefName: normalizedBaseBranch,
|
metadataBaseRefName: normalizedBaseBranch,
|
||||||
addArguments: ["-b", newBranchName, "--no-track", base],
|
addArguments: ["-b", newBranchName, "--no-track", base],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case "checkout-branch": {
|
case "checkout-branch":
|
||||||
await validateExistingWorktreeBranchName(cwd, source.branchName);
|
return resolveCheckoutBranchWorktreeSourcePlan({ cwd, branchName: 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-change-request":
|
case "checkout-change-request":
|
||||||
case "checkout-github-pr": {
|
case "checkout-github-pr":
|
||||||
const localBranchCandidate = source.localBranchName ?? source.headRef;
|
return resolveChangeRequestWorktreeSourcePlan({ cwd, source });
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
async function resolveCheckoutBranchWorktreeSourcePlan(options: {
|
||||||
branchName: localBranchName,
|
branchName: string;
|
||||||
metadataBaseRefName: normalizedBaseRefName,
|
cwd: string;
|
||||||
changeRequestLookupTarget: {
|
}): Promise<WorktreeSourcePlan> {
|
||||||
headRef: source.headRef,
|
await validateExistingWorktreeBranchName(options.cwd, options.branchName);
|
||||||
...(source.headRepositoryOwner
|
const needsFetch = !(await localBranchExists(options.cwd, options.branchName));
|
||||||
? { headRepositoryOwner: source.headRepositoryOwner }
|
let createdBranchOid: string | undefined;
|
||||||
: {}),
|
if (needsFetch) {
|
||||||
changeRequestNumber,
|
try {
|
||||||
},
|
createdBranchOid = await fetchNewLocalBranchAtomically({
|
||||||
addArguments: [localBranchName],
|
cwd: options.cwd,
|
||||||
...remotePlan,
|
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: {
|
async function configureWorktreePushRemote(options: {
|
||||||
@@ -1471,27 +1643,22 @@ async function fetchWorktreeCheckoutRefs(options: {
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
localBranchName: string;
|
localBranchName: string;
|
||||||
checkoutRefs: WorktreeCheckoutRef[];
|
checkoutRefs: WorktreeCheckoutRef[];
|
||||||
}): Promise<void> {
|
}): Promise<string> {
|
||||||
let lastResult:
|
let lastResult:
|
||||||
| Awaited<ReturnType<typeof runGitCommand>>
|
| Awaited<ReturnType<typeof runGitCommand>>
|
||||||
| { stderr: string; stdout: string; exitCode: number | null }
|
| { stderr: string; stdout: string; exitCode: number | null }
|
||||||
| null = null;
|
| null = null;
|
||||||
for (const checkoutRef of options.checkoutRefs) {
|
for (const checkoutRef of options.checkoutRefs) {
|
||||||
lastResult = await runGitCommand(
|
try {
|
||||||
[
|
return await fetchNewLocalBranchAtomically({
|
||||||
"fetch",
|
|
||||||
checkoutRef.remoteName ?? "origin",
|
|
||||||
`+${checkoutRef.remoteRef}:refs/heads/${options.localBranchName}`,
|
|
||||||
"--force",
|
|
||||||
],
|
|
||||||
{
|
|
||||||
cwd: options.cwd,
|
cwd: options.cwd,
|
||||||
timeout: 120_000,
|
localBranchName: options.localBranchName,
|
||||||
acceptExitCodes: [0, 1, 128],
|
remoteName: checkoutRef.remoteName ?? "origin",
|
||||||
},
|
remoteRef: checkoutRef.remoteRef,
|
||||||
);
|
});
|
||||||
if (lastResult.exitCode === 0) {
|
} catch (error) {
|
||||||
return;
|
lastResult =
|
||||||
|
error instanceof Error ? { stderr: error.message, stdout: "", exitCode: 1 } : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const attemptedRefs = options.checkoutRefs
|
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: {
|
async function tryFetchWorktreeTrackingRemote(options: {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
remoteName: 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).
|
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 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.
|
`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