mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-21 08:42:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa06701c9d | ||
|
|
a29729ed48 |
@@ -52,6 +52,8 @@ Connected clients are trusted operators of the daemon user. File previews follow
|
||||
|
||||
When Paseo checks out a change request from a different repository, it does not run that workspace's `paseo.json` setup, automatic terminals, named scripts, or teardown until you explicitly run setup for that workspace. The decision lasts for the workspace and does not re-prompt after new commits. Same-repository changes, ordinary branches, local workspaces, agent launches, terminals, explicit shell commands, and metadata-generation instructions are outside this gate.
|
||||
|
||||
An explicit `symlink <path>` entry in a repository's `.worktreeinclude` gives a Paseo-created worktree live access to that source-checkout file or directory. It is useful for local dependencies and caches, but it weakens the usual worktree isolation: agents and lifecycle scripts can modify the source through the link. Paseo validates entries and refuses traversal or destination-link escapes, but the linked source is a deliberate shared-data boundary.
|
||||
|
||||
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. Setting a password is strongly recommended in that case.
|
||||
|
||||
In Docker, the official image runs the daemon and agents as the non-root
|
||||
|
||||
@@ -50,6 +50,7 @@ than downloading a published desktop release.
|
||||
- **Repo dev scripts** default to `$ROOT/.dev/paseo-home`, where `$ROOT` is the current checkout or worktree root. This keeps all dev state scoped to the checkout instead of the packaged desktop app.
|
||||
- **`npm run cli -- ...`** runs through the same dev-home wrapper as the dev scripts, so the in-repo CLI automatically targets the current checkout's `.dev/paseo-home` and configured dev daemon endpoint.
|
||||
- **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying durable JSON metadata. Runtime files like pid files, sockets, and logs are not copied.
|
||||
- **Paseo-created worktrees** read `.worktreeinclude` from the live source checkout before creation. Bare paths and `copy <path>` copy a snapshot into the new worktree; `symlink <path>` creates a live source link. Missing paths, malformed entries, unsafe paths, incompatible include overlaps, destination conflicts, unavailable platform links, and ordinary read/write failures are skipped individually and reported in the daemon log, so the rest of the plan still runs. A source symlink is allowed only when its resolved target remains inside the active source checkout. If that checkout is itself Paseo-managed, its own paths remain eligible while other managed worktree paths stay protected; `copy` snapshots that resolved target, while `symlink` links directly to it. Hard links are ordinary files. Each include is staged before it is committed; Paseo aborts creation only if it cannot safely clean up partial materialization state (or Git/worktree setup itself fails). Materialization finishes before `worktree.setup` runs.
|
||||
- **This repo's worktree setup** also best-effort seeds `packages/app/ios` and the newest `.dev/ios-build` entry from the source checkout so iOS simulator services can reuse native project and Xcode cache state when it is safe enough to do so.
|
||||
|
||||
Override knobs:
|
||||
|
||||
@@ -29,6 +29,7 @@ import { resolveFirstAgentPromptTitle } from "./agent/create-agent-title.js";
|
||||
import { buildAgentBranchNameSeed } from "./agent/prompt-attachments.js";
|
||||
import type { FirstAgentContext } from "@getpaseo/protocol/messages";
|
||||
import { runWithGitCommandPriority } from "../utils/run-git-command.js";
|
||||
import type { WorktreeIncludeSummary } from "../utils/worktree-include.js";
|
||||
|
||||
export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {
|
||||
projectId?: string;
|
||||
@@ -37,6 +38,7 @@ export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {
|
||||
|
||||
export interface CreatePaseoWorktreeResult {
|
||||
worktree: WorktreeConfig;
|
||||
worktreeIncludeSummary?: WorktreeIncludeSummary;
|
||||
intent: WorktreeCreationIntent;
|
||||
workspace: PersistedWorkspaceRecord;
|
||||
repoRoot: string;
|
||||
@@ -117,6 +119,7 @@ async function createPaseoWorktreeWithPriority(
|
||||
|
||||
return {
|
||||
worktree: createdWorktree.worktree,
|
||||
worktreeIncludeSummary: createdWorktree.worktreeIncludeSummary,
|
||||
intent: createdWorktree.intent,
|
||||
workspace,
|
||||
repoRoot: createdWorktree.repoRoot,
|
||||
|
||||
@@ -873,6 +873,7 @@ export class Session {
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
this.workspaceRecovery = createWorkspaceRecoveryService({
|
||||
logger: this.sessionLogger,
|
||||
paseoHome: this.paseoHome,
|
||||
worktreesRoot: this.worktreesRoot,
|
||||
getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId),
|
||||
|
||||
+14
@@ -76,6 +76,7 @@ function createHarness(input?: {
|
||||
const directories = new Set(input?.directories ?? ["/repo"]);
|
||||
const unarchived: string[] = [];
|
||||
const service = createWorkspaceRecoveryService({
|
||||
logger: { warn: () => undefined } as never,
|
||||
paseoHome: input?.paseoHome ?? "/paseo-home",
|
||||
worktreesRoot: input?.worktreesRoot ?? "/worktrees",
|
||||
getWorkspace: async (workspaceId) =>
|
||||
@@ -135,6 +136,7 @@ describe("workspace recovery", () => {
|
||||
const sourceSubdirectory = join(repoDir, "packages", "app");
|
||||
mkdirSync(sourceSubdirectory, { recursive: true });
|
||||
writeFileSync(join(sourceSubdirectory, "README.md"), "app\n");
|
||||
writeFileSync(join(repoDir, ".worktreeinclude"), "missing.local\n");
|
||||
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "add app"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" });
|
||||
@@ -170,7 +172,9 @@ describe("workspace recovery", () => {
|
||||
mainRepoRoot: repoDir,
|
||||
});
|
||||
const unarchived: string[] = [];
|
||||
const warnings: unknown[][] = [];
|
||||
const service = createWorkspaceRecoveryService({
|
||||
logger: { warn: (...args: unknown[]) => warnings.push(args) } as never,
|
||||
paseoHome,
|
||||
worktreesRoot,
|
||||
getWorkspace: async (workspaceId) =>
|
||||
@@ -189,6 +193,15 @@ describe("workspace recovery", () => {
|
||||
expect(existsSync(worktreeRoot)).toBe(true);
|
||||
expect(existsSync(workspaceCwd)).toBe(true);
|
||||
expect(unarchived).toEqual([workspace.workspaceId]);
|
||||
expect(warnings).toEqual([
|
||||
[
|
||||
expect.objectContaining({
|
||||
materialized: 0,
|
||||
skipped: [expect.objectContaining({ raw: "missing.local", reason: "missing" })],
|
||||
}),
|
||||
"Worktree include completed with skipped entries during workspace recovery",
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps an exact-subdirectory workspace archived when its branch lacks that directory", async () => {
|
||||
@@ -220,6 +233,7 @@ describe("workspace recovery", () => {
|
||||
});
|
||||
const unarchived: string[] = [];
|
||||
const service = createWorkspaceRecoveryService({
|
||||
logger: { warn: () => undefined } as never,
|
||||
paseoHome,
|
||||
worktreesRoot,
|
||||
getWorkspace: async (workspaceId) =>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { basename } from "node:path";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import { createRealpathAwarePathMatcher } from "../../../utils/path.js";
|
||||
import { runGitCommand } from "../../../utils/run-git-command.js";
|
||||
@@ -59,6 +60,7 @@ type RecoveryPlan =
|
||||
type UnavailableRecoveryState = Extract<WorkspaceRecoveryState, { kind: "unavailable" }>;
|
||||
|
||||
export function createWorkspaceRecoveryService(deps: {
|
||||
logger: Logger;
|
||||
paseoHome: string;
|
||||
worktreesRoot?: string;
|
||||
getWorkspace: (workspaceId: string) => Promise<PersistedWorkspaceRecord | null>;
|
||||
@@ -195,6 +197,16 @@ export function createWorkspaceRecoveryService(deps: {
|
||||
worktreesRoot: deps.worktreesRoot,
|
||||
});
|
||||
recreatedWorktreePath = result.worktreePath;
|
||||
if (result.worktreeIncludeSummary.skipped.length > 0) {
|
||||
deps.logger.warn(
|
||||
{
|
||||
materialized: result.worktreeIncludeSummary.materialized,
|
||||
skipped: result.worktreeIncludeSummary.skipped,
|
||||
worktreePath: result.worktreePath,
|
||||
},
|
||||
"Worktree include completed with skipped entries during workspace recovery",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
throw toWorktreeRequestError(error);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
validateBranchSlug,
|
||||
type WorktreeConfig,
|
||||
} from "../utils/worktree.js";
|
||||
import type { WorktreeIncludeSummary } from "../utils/worktree-include.js";
|
||||
import {
|
||||
resolveWorktreeCreationIntent,
|
||||
type ResolveWorktreeCreationIntentInput,
|
||||
@@ -42,6 +43,7 @@ export interface CreateWorktreeCoreDeps {
|
||||
|
||||
export interface CreateWorktreeCoreResult {
|
||||
worktree: WorktreeConfig;
|
||||
worktreeIncludeSummary?: WorktreeIncludeSummary;
|
||||
intent: WorktreeCreationIntent;
|
||||
repoRoot: string;
|
||||
created: boolean;
|
||||
@@ -115,15 +117,17 @@ async function createWorktreeCoreWithPriority(
|
||||
}
|
||||
}
|
||||
|
||||
const { worktreeIncludeSummary, ...worktree } = await createWorktree({
|
||||
cwd: repoRoot,
|
||||
worktreeSlug: normalizedSlug,
|
||||
source: intent,
|
||||
runSetup: input.runSetup ?? true,
|
||||
paseoHome: input.paseoHome,
|
||||
worktreesRoot: input.worktreesRoot,
|
||||
});
|
||||
return {
|
||||
worktree: await createWorktree({
|
||||
cwd: repoRoot,
|
||||
worktreeSlug: normalizedSlug,
|
||||
source: intent,
|
||||
runSetup: input.runSetup ?? true,
|
||||
paseoHome: input.paseoHome,
|
||||
worktreesRoot: input.worktreesRoot,
|
||||
}),
|
||||
worktree,
|
||||
worktreeIncludeSummary,
|
||||
intent,
|
||||
repoRoot,
|
||||
created: true,
|
||||
|
||||
@@ -628,6 +628,17 @@ export async function createPaseoWorktreeWorkflow(
|
||||
const workspace = createdWorktree.workspace;
|
||||
const setupContinuation = options?.setupContinuation ?? { kind: "workspace" };
|
||||
|
||||
if (createdWorktree.created && createdWorktree.worktreeIncludeSummary?.skipped.length) {
|
||||
dependencies.sessionLogger.warn(
|
||||
{
|
||||
materialized: createdWorktree.worktreeIncludeSummary.materialized,
|
||||
skipped: createdWorktree.worktreeIncludeSummary.skipped,
|
||||
worktreePath: createdWorktree.worktree.worktreePath,
|
||||
},
|
||||
"Worktree include completed with skipped entries",
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await dependencies.assertWorkspaceAutomationAllowed?.(workspace.workspaceId);
|
||||
} catch (error) {
|
||||
|
||||
@@ -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
@@ -382,6 +382,68 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
expect(metadata).toMatchObject({ baseRefName: "main" });
|
||||
});
|
||||
|
||||
it("removes fetched branches when include planning fails", async () => {
|
||||
const remoteDir = join(tempDir, "remote.git");
|
||||
const remoteCloneDir = join(tempDir, "remote-clone");
|
||||
execFileSync("git", ["clone", "--bare", repoDir, remoteDir]);
|
||||
execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir });
|
||||
|
||||
execFileSync("git", ["clone", remoteDir, remoteCloneDir]);
|
||||
execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: remoteCloneDir });
|
||||
execFileSync("git", ["config", "user.name", "Test"], { cwd: remoteCloneDir });
|
||||
execFileSync("git", ["checkout", "-b", "contributor/cleanup"], { cwd: remoteCloneDir });
|
||||
writeFileSync(join(remoteCloneDir, "file.txt"), "from-pr\n");
|
||||
execFileSync("git", ["add", "file.txt"], { cwd: remoteCloneDir });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "pr branch"], {
|
||||
cwd: remoteCloneDir,
|
||||
});
|
||||
const prHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: remoteCloneDir })
|
||||
.toString()
|
||||
.trim();
|
||||
execFileSync("git", ["push", "origin", "contributor/cleanup"], { cwd: remoteCloneDir });
|
||||
execFileSync("git", [`--git-dir=${remoteDir}`, "update-ref", "refs/pull/44/head", prHead]);
|
||||
mkdirSync(join(repoDir, ".worktreeinclude"));
|
||||
|
||||
await expect(
|
||||
createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "pr-44-cleanup",
|
||||
source: {
|
||||
kind: "checkout-github-pr",
|
||||
githubPrNumber: 44,
|
||||
headRef: "contributor/cleanup",
|
||||
baseRefName: "main",
|
||||
},
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "EISDIR" });
|
||||
|
||||
expect(() =>
|
||||
execFileSync("git", ["show-ref", "--verify", "--quiet", "refs/heads/contributor/cleanup"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
}),
|
||||
).toThrow();
|
||||
|
||||
await expect(
|
||||
createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "branch-cleanup",
|
||||
source: { kind: "checkout-branch", branchName: "contributor/cleanup" },
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "EISDIR" });
|
||||
|
||||
expect(() =>
|
||||
execFileSync("git", ["show-ref", "--verify", "--quiet", "refs/heads/contributor/cleanup"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("fetches a GitHub PR branch when the head ref contains uppercase letters and dots", async () => {
|
||||
const remoteDir = join(tempDir, "remote.git");
|
||||
const remoteCloneDir = join(tempDir, "remote-clone");
|
||||
@@ -1298,6 +1360,241 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
it("materializes copies and symlinks before setup, then removes only the new worktree links", async () => {
|
||||
writeFileSync(
|
||||
join(repoDir, ".gitignore"),
|
||||
[".copy.env", "copy-cache/", "linked-file.txt", "linked-state", "setup.log", ""].join("\n"),
|
||||
);
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
setup: [
|
||||
"test -f .copy.env",
|
||||
"test -L linked-file.txt",
|
||||
"test -L linked-state",
|
||||
"cat linked-state/state.txt > setup.log",
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
execFileSync("git", ["add", ".gitignore", "paseo.json"], { cwd: repoDir });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add include fixture"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
|
||||
writeFileSync(
|
||||
join(repoDir, ".worktreeinclude"),
|
||||
[".copy.env", "copy-cache/**", "symlink linked-file.txt", "symlink linked-state", ""].join(
|
||||
"\n",
|
||||
),
|
||||
);
|
||||
writeFileSync(join(repoDir, ".copy.env"), "copy-v1\n");
|
||||
mkdirSync(join(repoDir, "copy-cache"), { recursive: true });
|
||||
writeFileSync(join(repoDir, "copy-cache", "state.txt"), "copy-cache-v1\n");
|
||||
writeFileSync(join(repoDir, "linked-file.txt"), "linked-file-v1\n");
|
||||
mkdirSync(join(repoDir, "linked-state"), { recursive: true });
|
||||
writeFileSync(join(repoDir, "linked-state", "state.txt"), "linked-state-v1\n");
|
||||
|
||||
const result = await createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "include-links",
|
||||
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/include-links" },
|
||||
runSetup: true,
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
expect(readFileSync(join(result.worktreePath, "setup.log"), "utf8")).toBe(
|
||||
"linked-state-v1\n",
|
||||
);
|
||||
expect(lstatSync(join(result.worktreePath, ".copy.env")).isSymbolicLink()).toBe(false);
|
||||
expect(lstatSync(join(result.worktreePath, "copy-cache")).isSymbolicLink()).toBe(false);
|
||||
expect(lstatSync(join(result.worktreePath, "linked-file.txt")).isSymbolicLink()).toBe(true);
|
||||
expect(lstatSync(join(result.worktreePath, "linked-state")).isSymbolicLink()).toBe(true);
|
||||
expect(
|
||||
execFileSync("git", ["status", "--porcelain"], {
|
||||
cwd: result.worktreePath,
|
||||
encoding: "utf8",
|
||||
}),
|
||||
).toBe("");
|
||||
|
||||
writeFileSync(join(repoDir, ".copy.env"), "copy-v2\n");
|
||||
writeFileSync(join(repoDir, "copy-cache", "state.txt"), "copy-cache-v2\n");
|
||||
writeFileSync(join(repoDir, "linked-file.txt"), "linked-file-v2\n");
|
||||
writeFileSync(join(repoDir, "linked-state", "state.txt"), "linked-state-v2\n");
|
||||
|
||||
expect(readFileSync(join(result.worktreePath, ".copy.env"), "utf8")).toBe("copy-v1\n");
|
||||
expect(readFileSync(join(result.worktreePath, "copy-cache", "state.txt"), "utf8")).toBe(
|
||||
"copy-cache-v1\n",
|
||||
);
|
||||
expect(readFileSync(join(result.worktreePath, "linked-file.txt"), "utf8")).toBe(
|
||||
"linked-file-v2\n",
|
||||
);
|
||||
expect(readFileSync(join(result.worktreePath, "linked-state", "state.txt"), "utf8")).toBe(
|
||||
"linked-state-v2\n",
|
||||
);
|
||||
|
||||
await deletePaseoWorktree({
|
||||
cwd: repoDir,
|
||||
worktreePath: result.worktreePath,
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
expect(existsSync(result.worktreePath)).toBe(false);
|
||||
expect(readFileSync(join(repoDir, "linked-file.txt"), "utf8")).toBe("linked-file-v2\n");
|
||||
expect(readFileSync(join(repoDir, "linked-state", "state.txt"), "utf8")).toBe(
|
||||
"linked-state-v2\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("skips missing includes and materializes paths that exist", async () => {
|
||||
const projectHash = await deriveWorktreeProjectHash(repoDir);
|
||||
const expectedWorktreePath = join(paseoHome, "worktrees", projectHash, "missing-include");
|
||||
writeFileSync(
|
||||
join(repoDir, ".worktreeinclude"),
|
||||
[".env", ".env.local", ".sops.yaml", ""].join("\n"),
|
||||
);
|
||||
writeFileSync(join(repoDir, ".env"), "present\n");
|
||||
|
||||
const result = await createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "missing-include",
|
||||
source: { kind: "branch-off", baseBranch: "main", branchName: "feature/missing-include" },
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
expect(result.worktreePath).toBe(expectedWorktreePath);
|
||||
expect(readFileSync(join(result.worktreePath, ".env"), "utf8")).toBe("present\n");
|
||||
expect(existsSync(join(result.worktreePath, ".env.local"))).toBe(false);
|
||||
expect(existsSync(join(result.worktreePath, ".sops.yaml"))).toBe(false);
|
||||
expect(
|
||||
execFileSync("git", ["worktree", "list", "--porcelain"], {
|
||||
cwd: repoDir,
|
||||
encoding: "utf8",
|
||||
}),
|
||||
).toContain(expectedWorktreePath);
|
||||
});
|
||||
|
||||
it("skips checkout-local worktree storage and creates the remaining includes", async () => {
|
||||
const checkoutLocalPaseoHome = join(repoDir, ".dev", "paseo-home");
|
||||
const projectHash = await deriveWorktreeProjectHash(repoDir);
|
||||
const expectedWorktreePath = join(
|
||||
checkoutLocalPaseoHome,
|
||||
"worktrees",
|
||||
projectHash,
|
||||
"protected-include",
|
||||
);
|
||||
mkdirSync(join(checkoutLocalPaseoHome, "worktrees", projectHash), { recursive: true });
|
||||
writeFileSync(join(repoDir, ".worktreeinclude"), [".dev/**", ".env", ""].join("\n"));
|
||||
writeFileSync(join(repoDir, ".env"), "present\n");
|
||||
|
||||
const result = await createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "protected-include",
|
||||
source: {
|
||||
kind: "branch-off",
|
||||
baseBranch: "main",
|
||||
branchName: "feature/protected-include",
|
||||
},
|
||||
runSetup: false,
|
||||
paseoHome: checkoutLocalPaseoHome,
|
||||
});
|
||||
|
||||
expect(result.worktreePath).toBe(expectedWorktreePath);
|
||||
expect(readFileSync(join(result.worktreePath, ".env"), "utf8")).toBe("present\n");
|
||||
expect(result.worktreeIncludeSummary?.skipped).toEqual([
|
||||
expect.objectContaining({ raw: ".dev/**", reason: "unsafe" }),
|
||||
]);
|
||||
expect(
|
||||
execFileSync("git", ["worktree", "list", "--porcelain"], {
|
||||
cwd: repoDir,
|
||||
encoding: "utf8",
|
||||
}),
|
||||
).toContain(expectedWorktreePath);
|
||||
expect(
|
||||
execFileSync("git", ["branch", "--list", "feature/protected-include"], {
|
||||
cwd: repoDir,
|
||||
encoding: "utf8",
|
||||
}).trim(),
|
||||
).toContain("feature/protected-include");
|
||||
});
|
||||
|
||||
it("keeps includes when branching from a Paseo-managed worktree", async () => {
|
||||
writeFileSync(join(repoDir, ".gitignore"), ".env\n");
|
||||
writeFileSync(join(repoDir, ".worktreeinclude"), ".env\n");
|
||||
execFileSync("git", ["add", ".gitignore", ".worktreeinclude"], { cwd: repoDir });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add include config"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
writeFileSync(join(repoDir, ".env"), "source\n");
|
||||
|
||||
const sourceWorktree = await createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "include-source",
|
||||
source: {
|
||||
kind: "branch-off",
|
||||
baseBranch: "main",
|
||||
branchName: "feature/include-source",
|
||||
},
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
const nestedWorktree = await createLegacyWorktreeForTest({
|
||||
cwd: sourceWorktree.worktreePath,
|
||||
worktreeSlug: "include-nested",
|
||||
source: {
|
||||
kind: "branch-off",
|
||||
baseBranch: "feature/include-source",
|
||||
branchName: "feature/include-nested",
|
||||
},
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
expect(readFileSync(join(sourceWorktree.worktreePath, ".env"), "utf8")).toBe("source\n");
|
||||
expect(readFileSync(join(nestedWorktree.worktreePath, ".env"), "utf8")).toBe("source\n");
|
||||
expect(nestedWorktree.worktreeIncludeSummary?.skipped).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips a symlink include conflict and keeps the new worktree", async () => {
|
||||
const projectHash = await deriveWorktreeProjectHash(repoDir);
|
||||
const expectedWorktreePath = join(paseoHome, "worktrees", projectHash, "include-conflict");
|
||||
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify({ scripts: {} }));
|
||||
writeFileSync(join(repoDir, ".worktreeinclude"), "symlink paseo.json\n");
|
||||
|
||||
const result = await createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "include-conflict",
|
||||
source: {
|
||||
kind: "branch-off",
|
||||
baseBranch: "main",
|
||||
branchName: "feature/include-conflict",
|
||||
},
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
expect(result.worktreePath).toBe(expectedWorktreePath);
|
||||
expect(existsSync(expectedWorktreePath)).toBe(true);
|
||||
expect(lstatSync(join(expectedWorktreePath, "paseo.json")).isSymbolicLink()).toBe(false);
|
||||
expect(result.worktreeIncludeSummary?.skipped).toEqual([
|
||||
expect.objectContaining({ raw: "symlink paseo.json", reason: "conflict" }),
|
||||
]);
|
||||
expect(
|
||||
execFileSync("git", ["worktree", "list", "--porcelain"], {
|
||||
cwd: repoDir,
|
||||
encoding: "utf8",
|
||||
}),
|
||||
).toContain(expectedWorktreePath);
|
||||
expect(
|
||||
execFileSync("git", ["branch", "--list", "feature/include-conflict"], {
|
||||
cwd: repoDir,
|
||||
encoding: "utf8",
|
||||
}).trim(),
|
||||
).toContain("feature/include-conflict");
|
||||
});
|
||||
|
||||
it("creates a worktree without error when no paseo.json exists in the main repo", async () => {
|
||||
const result = await createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
|
||||
@@ -44,6 +44,11 @@ import { createExternalProcessEnv } from "../server/paseo-env.js";
|
||||
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
|
||||
import { expandTilde, getRealpathAwareRelativePath, isPathInsideRoot } from "./path.js";
|
||||
import { terminateWithTreeKill } from "./tree-kill.js";
|
||||
import {
|
||||
materializeWorktreeIncludePlan,
|
||||
readWorktreeIncludePlan,
|
||||
type WorktreeIncludeSummary,
|
||||
} from "./worktree-include.js";
|
||||
|
||||
export { slugify, validateBranchSlug } from "@getpaseo/protocol/branch-slug";
|
||||
|
||||
@@ -57,6 +62,10 @@ export interface WorktreeConfig {
|
||||
worktreePath: string;
|
||||
}
|
||||
|
||||
export interface CreatedWorktreeConfig extends WorktreeConfig {
|
||||
worktreeIncludeSummary: WorktreeIncludeSummary;
|
||||
}
|
||||
|
||||
export interface WorktreeRuntimeEnv {
|
||||
[key: string]: string;
|
||||
PASEO_SOURCE_CHECKOUT_PATH: string;
|
||||
@@ -1145,12 +1154,18 @@ export async function deletePaseoWorktree({
|
||||
}
|
||||
|
||||
export async function rollbackCreatedPaseoWorktree(
|
||||
options: DeletePaseoWorktreeOptions,
|
||||
options: DeletePaseoWorktreeOptions & { createdBranchName?: string },
|
||||
cause: unknown,
|
||||
): Promise<never> {
|
||||
let cleanupError: unknown;
|
||||
try {
|
||||
await deletePaseoWorktree(options);
|
||||
if (options.createdBranchName && options.cwd) {
|
||||
await runGitCommand(["branch", "--delete", "--force", options.createdBranchName], {
|
||||
cwd: options.cwd,
|
||||
acceptExitCodes: [0, 1],
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
cleanupError = error;
|
||||
}
|
||||
@@ -1216,8 +1231,25 @@ export const createWorktree = async ({
|
||||
runSetup,
|
||||
paseoHome,
|
||||
worktreesRoot,
|
||||
}: CreateWorktreeOptions): Promise<WorktreeConfig> => {
|
||||
}: CreateWorktreeOptions): Promise<CreatedWorktreeConfig> => {
|
||||
const sourcePlan = await resolveWorktreeSourcePlan({ cwd, source, desiredSlug: worktreeSlug });
|
||||
const paseoWorktreesBaseRoot = resolvePaseoWorktreesBaseRoot({ paseoHome, worktreesRoot });
|
||||
const worktreeIncludePlan = await (async () => {
|
||||
try {
|
||||
return await readWorktreeIncludePlan({
|
||||
sourceRoot: cwd,
|
||||
excludedSourceRoots: [paseoWorktreesBaseRoot],
|
||||
});
|
||||
} catch (error) {
|
||||
if (sourcePlan.createdBranchName) {
|
||||
await runGitCommand(["branch", "--delete", "--force", sourcePlan.createdBranchName], {
|
||||
cwd,
|
||||
acceptExitCodes: [0, 1],
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
let worktreePath = join(await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot), worktreeSlug);
|
||||
mkdirSync(dirname(worktreePath), { recursive: true });
|
||||
|
||||
@@ -1236,30 +1268,56 @@ export const createWorktree = async ({
|
||||
});
|
||||
worktreePath = normalizePathForOwnership(finalWorktreePath);
|
||||
|
||||
if (sourcePlan.pushRemote) {
|
||||
await configureWorktreePushRemote({
|
||||
cwd,
|
||||
branchName: sourcePlan.branchName,
|
||||
remote: sourcePlan.pushRemote,
|
||||
});
|
||||
}
|
||||
if (sourcePlan.trackingRemote) {
|
||||
await configureWorktreeTrackingRemote({
|
||||
cwd,
|
||||
branchName: sourcePlan.branchName,
|
||||
remote: sourcePlan.trackingRemote,
|
||||
});
|
||||
}
|
||||
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.metadataBaseRef ? { baseRef: sourcePlan.metadataBaseRef } : {}),
|
||||
...(sourcePlan.changeRequestLookupTarget
|
||||
? { changeRequestLookupTarget: sourcePlan.changeRequestLookupTarget }
|
||||
: {}),
|
||||
});
|
||||
writePaseoWorktreeMetadata(worktreePath, {
|
||||
baseRefName: sourcePlan.metadataBaseRefName,
|
||||
...(sourcePlan.metadataBaseRef ? { baseRef: sourcePlan.metadataBaseRef } : {}),
|
||||
...(sourcePlan.changeRequestLookupTarget
|
||||
? { changeRequestLookupTarget: sourcePlan.changeRequestLookupTarget }
|
||||
: {}),
|
||||
});
|
||||
|
||||
await seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath });
|
||||
await seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath });
|
||||
const materialization = await materializeWorktreeIncludePlan({
|
||||
plan: worktreeIncludePlan,
|
||||
worktreeRoot: worktreePath,
|
||||
});
|
||||
worktreeIncludeSummary = {
|
||||
materialized: materialization.materialized,
|
||||
skipped: [...worktreeIncludePlan.skipped, ...materialization.skipped],
|
||||
};
|
||||
} catch (error) {
|
||||
return rollbackCreatedPaseoWorktree(
|
||||
{
|
||||
cwd,
|
||||
worktreePath,
|
||||
teardownCwds: [],
|
||||
paseoHome,
|
||||
worktreesBaseRoot: worktreesRoot,
|
||||
createdBranchName: sourcePlan.createdBranchName,
|
||||
},
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
if (runSetup) {
|
||||
await runWorktreeSetupCommands({
|
||||
@@ -1271,6 +1329,7 @@ export const createWorktree = async ({
|
||||
|
||||
return {
|
||||
branchName: sourcePlan.branchName,
|
||||
worktreeIncludeSummary,
|
||||
worktreePath,
|
||||
};
|
||||
};
|
||||
@@ -1283,6 +1342,7 @@ interface ResolveWorktreeSourcePlanOptions {
|
||||
|
||||
interface WorktreeSourcePlan {
|
||||
branchName: string;
|
||||
createdBranchName?: string;
|
||||
// Display name and exact ref are two different facts. The name cannot round-trip to a
|
||||
// commit — "main" resolves local-first even when the worktree was cut from a fork's
|
||||
// upstream — so comparisons and actions read the ref and the UI reads the name.
|
||||
@@ -1320,6 +1380,7 @@ async function resolveWorktreeSourcePlan({
|
||||
|
||||
return {
|
||||
branchName: newBranchName,
|
||||
createdBranchName: newBranchName,
|
||||
metadataBaseRefName: normalizedBaseBranch,
|
||||
metadataBaseRef: resolvedBaseBranch,
|
||||
changeRequestLookupTarget: createPaseoWorktreeChangeRequestHint({
|
||||
@@ -1340,11 +1401,22 @@ async function resolveWorktreeSourcePlan({
|
||||
} catch {
|
||||
throw new UnknownBranchError({ branchName: source.branchName, cwd });
|
||||
}
|
||||
return {
|
||||
branchName: source.branchName,
|
||||
createdBranchName: source.branchName,
|
||||
metadataBaseRefName: source.branchName,
|
||||
changeRequestLookupTarget: createPaseoWorktreeChangeRequestHint({
|
||||
headRef: source.branchName,
|
||||
localBranchName: source.branchName,
|
||||
}),
|
||||
addArguments: [source.branchName],
|
||||
};
|
||||
}
|
||||
if (await isBranchCheckedOut(cwd, source.branchName)) {
|
||||
const branchName = await resolveUniqueLocalBranchName(cwd, source.branchName);
|
||||
return {
|
||||
branchName,
|
||||
createdBranchName: branchName,
|
||||
metadataBaseRefName: source.branchName,
|
||||
changeRequestLookupTarget: createPaseoWorktreeChangeRequestHint({
|
||||
headRef: branchName,
|
||||
@@ -1413,6 +1485,7 @@ async function resolveWorktreeSourcePlan({
|
||||
|
||||
return {
|
||||
branchName: localBranchName,
|
||||
createdBranchName: localBranchName,
|
||||
metadataBaseRefName: normalizedBaseRefName,
|
||||
changeRequestLookupTarget: createPaseoWorktreeChangeRequestHint({
|
||||
headRef: source.headRef,
|
||||
|
||||
@@ -96,8 +96,6 @@ worktree:
|
||||
|
||||
For agents it dispatched, Hub owns creation, reconnect recovery, output observation, and completion. Agents you start yourself are untouched.
|
||||
|
||||
Restarting Hub lets active agents continue working. Hub reconnects to the same agents and resumes tracking their output and completion. [GitHub credentials retain their original lifetime](/docs/hub/github#restarts-and-credential-lifetime).
|
||||
|
||||
If Hub loses the create response, or the daemon restarts mid-execution, Hub resends the same create intent with the same execution id. The daemon returns the existing agent rather than running the prompt again. An agent that closed or errored is recorded as interrupted; Hub never silently starts a second one.
|
||||
|
||||
## Status
|
||||
|
||||
@@ -37,7 +37,7 @@ steps:
|
||||
${{ paseo.prompt }}
|
||||
```
|
||||
|
||||
The agent can use `git` and `gh` within the declared repositories and permissions. Hub mints the token when the step starts and revokes it when execution ends or its configured duration elapses.
|
||||
The agent can use `git` and `gh` within the declared repositories and permissions. Hub mints the token when the step starts and revokes it when execution ends.
|
||||
|
||||
## Fields
|
||||
|
||||
@@ -50,12 +50,6 @@ The agent can use `git` and `gh` within the declared repositories and permission
|
||||
|
||||
Requested authority cannot exceed the GitHub App installation. Activation and dispatch fail clearly when the connection, repository, or permissions cannot be resolved.
|
||||
|
||||
## Restarts and credential lifetime
|
||||
|
||||
Restarting Hub preserves active executions and their existing credentials. Hub reconnects to the same agent and restores the original credential deadlines. A restart does not mint a replacement token or extend its lifetime.
|
||||
|
||||
If Hub is unavailable when a shorter duration elapses, it revokes the overdue token when it returns. GitHub’s own expiry still limits the token lifetime to one hour. Revocation failures are retried after recovery.
|
||||
|
||||
## Agent environment
|
||||
|
||||
Hub supplies `GH_TOKEN` and process-scoped git configuration through environment variables:
|
||||
@@ -79,8 +73,4 @@ env:
|
||||
SOME_TOKEN: "${{ paseo.connections.some-connection.token }}"
|
||||
```
|
||||
|
||||
Hub persists resolved values as private execution data so it can recover after a restart. Authored configuration retains the expressions.
|
||||
|
||||
These values are stored in the Hub database without application-level encryption. Protect database access, storage, and backups as credentials. Hub deletes the execution authority record when the execution becomes terminal; token lease records remain until revocation succeeds or the token expires, so interrupted cleanup can resume. This cleanup is not a guarantee that every copy is erased: retained agent/session data and database backups may still contain resolved values. Token revocation ends access; it does not erase those copies. Backup retention and deletion remain the operator’s responsibility.
|
||||
|
||||
See [Hub security](/docs/hub/security) for provider and host boundaries.
|
||||
Hub resolves the value for the step and does not persist it. See [Hub security](/docs/hub/security) for provider and host boundaries.
|
||||
|
||||
@@ -112,6 +112,46 @@ Both fields accept a multiline shell script or an array of commands; commands ru
|
||||
|
||||
Commands run with the worktree as `cwd`. Use `$PASEO_SOURCE_CHECKOUT_PATH` to reach files in the original checkout (untracked config, local caches, etc).
|
||||
|
||||
## .worktreeinclude
|
||||
|
||||
Use a root-level .worktreeinclude to materialize local source-checkout files before
|
||||
worktree.setup runs. Each path is relative to the source checkout.
|
||||
|
||||
# Copy is the default.
|
||||
.env.local
|
||||
.cache/**
|
||||
|
||||
# Modes can also be explicit.
|
||||
symlink node_modules
|
||||
copy .tool-state/**
|
||||
|
||||
Each line is `[copy|symlink] <path>`; the mode is optional and defaults to `copy`. Blank lines
|
||||
and whole-line comments are ignored. A single star matches within a path segment and a double
|
||||
star matches recursively; a directory ending in /\*\* materializes that directory as one
|
||||
recursive entry. Absolute paths, parent-directory paths, and .git paths are rejected.
|
||||
|
||||
Copy entries are independent snapshots: a copied file or directory replaces an existing path on
|
||||
a later materialization. Symlink entries point directly at
|
||||
the live source file or directory, so changes through either path affect the same data. Paseo
|
||||
does not replace an existing file, directory, or different link with a symlink.
|
||||
|
||||
Entries must resolve to regular files or directories. A top-level source symbolic link is
|
||||
dereferenced only when its canonical target remains inside the source checkout: `copy` snapshots
|
||||
that target and `symlink` links directly to it. Directory snapshots reject nested symbolic links
|
||||
so Paseo never writes through an unexpected path. A symlinked directory intentionally exposes its
|
||||
live source contents.
|
||||
|
||||
Prefer paths ignored by the target branch. For a symlinked directory, use an ignore rule without
|
||||
a trailing slash (for example, node_modules, not node_modules/), because Git treats the link
|
||||
itself as a file. Unignored materialized paths appear in git status.
|
||||
|
||||
On Windows, Paseo uses junctions for local directories. File links and network-directory links
|
||||
require Windows symbolic-link support. It never silently copies an explicit `symlink <path>`
|
||||
entry; enable Developer Mode or switch that entry to `copy <path>` if link creation fails.
|
||||
|
||||
Archiving removes only the worktree's links, not their source targets. If the source path is
|
||||
later moved or deleted, a symlink becomes broken; Paseo does not repair it automatically.
|
||||
|
||||
## Scripts and services
|
||||
|
||||
`scripts` are named commands you can run inside a worktree on demand. Mark one as a _service_ and Paseo supervises it as a long-running process, assigns it a port, and routes HTTP traffic to it through the daemon's reverse proxy.
|
||||
|
||||
Reference in New Issue
Block a user