Merge main into project migration branch

This commit is contained in:
Mohamed Boudra
2026-07-19 00:28:33 +02:00
407 changed files with 31578 additions and 9380 deletions
+128 -9
View File
@@ -8,10 +8,12 @@ import {
readFileSync,
realpathSync,
mkdirSync,
statSync,
} from "fs";
import { join } from "path";
import { win32 } from "node:path";
import { tmpdir } from "os";
import pino from "pino";
import {
__resetCheckoutShortstatCacheForTests,
__resetPullRequestStatusCacheForTests,
@@ -46,7 +48,11 @@ import { startGitCommandMetrics, stopGitCommandMetrics } from "./run-git-command
import { createForgeResolver } from "../services/forge-resolver.js";
import { GitHubCommandError, GitHubCliMissingError } from "../services/github-service.js";
import type { CurrentPullRequestStatus, ForgeService } from "../services/forge-service.js";
import { TeaAuthenticationError, TeaCliMissingError } from "../services/gitea-service.js";
import {
TeaAuthenticationError,
TeaCliMissingError,
TeaCommandError,
} from "../services/gitea-service.js";
import {
createWorktree as createWorktreePrimitive,
type CreateWorktreeOptions,
@@ -159,6 +165,7 @@ function createPullRequestStatus(overrides?: Partial<CurrentPullRequestStatus>)
interface RequestedPullRequestTarget {
headRef: string;
headSha?: string;
headRepositoryOwner?: string;
}
@@ -174,6 +181,7 @@ function createGitHubServiceRecordingPullRequestTargets(
github.getCurrentPullRequestStatus = async (request) => {
options.requestedTargets.push({
headRef: request.headRef,
...(request.headSha ? { headSha: request.headSha } : {}),
...(request.headRepositoryOwner ? { headRepositoryOwner: request.headRepositoryOwner } : {}),
});
return createPullRequestStatus({
@@ -307,6 +315,45 @@ describe("checkout git utilities", () => {
});
});
it("reports a real non-git directory as non-git", async () => {
const nonGitDir = join(tempDir, "not-git-status");
mkdirSync(nonGitDir, { recursive: true });
await expect(getCheckoutStatus(nonGitDir)).resolves.toEqual({ isGit: false });
});
it.runIf(
process.platform !== "win32" &&
existsSync("/dev/shm") &&
statSync("/dev").dev !== statSync("/dev/shm").dev,
)("warns and reports a non-git directory at a filesystem boundary as non-git", async () => {
const nonGitDir = realpathSync.native(mkdtempSync("/dev/shm/checkout-git-boundary-test-"));
const records: unknown[] = [];
const logger = pino(
{ level: "warn" },
{
write(line: string) {
records.push(JSON.parse(line));
},
},
);
try {
await expect(getCheckoutStatus(nonGitDir, { logger })).resolves.toEqual({ isGit: false });
expect(records).toEqual([
expect.objectContaining({
level: 40,
cwd: nonGitDir,
msg: "Git worktree discovery failed; treating directory as non-Git",
err: expect.objectContaining({
message: expect.stringContaining("Stopping at filesystem boundary"),
}),
}),
]);
} finally {
rmSync(nonGitDir, { recursive: true, force: true });
}
});
it("returns null for getCurrentBranch in a repo with no commits", async () => {
const emptyRepo = join(tempDir, "empty-repo");
mkdirSync(emptyRepo, { recursive: true });
@@ -2300,7 +2347,8 @@ const x = 1;
const lookupTarget = await readPullRequestLookupTargetFromFacts(repoDir, paseoHome);
expect(lookupTarget).toEqual({ headRef: "refactor/workspace-scripts" });
expect(lookupTarget).toMatchObject({ headRef: "refactor/workspace-scripts" });
expect(lookupTarget?.headSha).toMatch(/^[0-9a-f]{40}$/);
});
it("keeps the local branch lookup when origin tracking uses the same head name", async () => {
@@ -2315,7 +2363,8 @@ const x = 1;
const lookupTarget = await readPullRequestLookupTargetFromFacts(repoDir, paseoHome);
expect(lookupTarget).toEqual({ headRef: "feature" });
expect(lookupTarget).toMatchObject({ headRef: "feature" });
expect(lookupTarget?.headSha).toMatch(/^[0-9a-f]{40}$/);
});
it("does not attach an owner when the tracked remote is the same GitHub repository", async () => {
@@ -2337,7 +2386,8 @@ const x = 1;
const lookupTarget = await readPullRequestLookupTargetFromFacts(repoDir, paseoHome);
expect(lookupTarget).toEqual({ headRef: "refactor/workspace-scripts" });
expect(lookupTarget).toMatchObject({ headRef: "refactor/workspace-scripts" });
expect(lookupTarget?.headSha).toMatch(/^[0-9a-f]{40}$/);
});
it("keeps the fork owner when same-repo comparison is indeterminate", async () => {
@@ -2355,7 +2405,8 @@ const x = 1;
const lookupTarget = await readPullRequestLookupTargetFromFacts(repoDir, paseoHome);
expect(lookupTarget).toEqual({ headRef: "main", headRepositoryOwner: "chethanuk" });
expect(lookupTarget).toMatchObject({ headRef: "main", headRepositoryOwner: "chethanuk" });
expect(lookupTarget?.headSha).toMatch(/^[0-9a-f]{40}$/);
});
it("uses the configured push remote for fork PR lookup when upstream is absent", async () => {
@@ -2384,8 +2435,11 @@ const x = 1;
);
expect(getBranchUpstream(repoDir)).toBeNull();
expect(factsTarget).toEqual({ headRef: "main", headRepositoryOwner: "chethanuk" });
expect(requestedTargets).toEqual([{ headRef: "main", headRepositoryOwner: "chethanuk" }]);
expect(factsTarget).toMatchObject({ headRef: "main", headRepositoryOwner: "chethanuk" });
expect(factsTarget?.headSha).toMatch(/^[0-9a-f]{40}$/);
expect(requestedTargets).toEqual([
expect.objectContaining({ headRef: "main", headRepositoryOwner: "chethanuk" }),
]);
});
it("keeps the local branch lookup when same-repo tracking points at the base branch", async () => {
@@ -2400,7 +2454,8 @@ const x = 1;
const lookupTarget = await readPullRequestLookupTargetFromFacts(repoDir, paseoHome);
expect(lookupTarget).toEqual({ headRef: "tender-parrot" });
expect(lookupTarget).toMatchObject({ headRef: "tender-parrot" });
expect(lookupTarget?.headSha).toMatch(/^[0-9a-f]{40}$/);
});
it("derives the same origin tracked head for on-demand PR status reads", async () => {
@@ -2454,7 +2509,9 @@ const x = 1;
const status = await getPullRequestStatus(repoDir, github);
expect(requestedTargets).toEqual([{ headRef: "main", headRepositoryOwner: "chethanuk" }]);
expect(requestedTargets).toEqual([
expect.objectContaining({ headRef: "main", headRepositoryOwner: "chethanuk" }),
]);
expect(status.status?.number).toBe(345);
expect(status.status?.headRefName).toBe("main");
});
@@ -2503,6 +2560,33 @@ const x = 1;
expect(callCount).toBe(1);
});
it("does not reuse a PR status cache entry after HEAD changes on the same branch", async () => {
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], {
cwd: repoDir,
});
const requestedShas: string[] = [];
const github = createGitHubServiceForStatus(null);
github.getCurrentPullRequestStatus = async (options) => {
if (options.headSha) requestedShas.push(options.headSha);
return createPullRequestStatus({
url: `https://github.com/getpaseo/paseo/pull/${requestedShas.length}`,
});
};
const first = await getPullRequestStatus(repoDir, github);
writeFileSync(join(repoDir, "next.txt"), "next\n");
execFileSync("git", ["add", "next.txt"], { cwd: repoDir });
execFileSync("git", ["commit", "-m", "next commit"], { cwd: repoDir });
const second = await getPullRequestStatus(repoDir, github);
expect(first.status?.url).toContain("/pull/1");
expect(second.status?.url).toContain("/pull/2");
expect(requestedShas).toHaveLength(2);
expect(requestedShas[0]).not.toBe(requestedShas[1]);
});
it("passes forced PR status reads through to the GitHub service", async () => {
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], {
@@ -2596,6 +2680,41 @@ const x = 1;
}
});
it("keeps stale PR status when a Gitea-family refresh hits a transient command error", async () => {
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
execFileSync("git", ["remote", "add", "origin", "https://gitea.example.com/acme/repo.git"], {
cwd: repoDir,
});
__setPullRequestStatusCacheTtlForTests(50);
try {
let callCount = 0;
const service = createGitHubServiceForStatus(null);
service.getCurrentPullRequestStatus = async () => {
callCount += 1;
if (callCount === 1) {
return createPullRequestStatus({ url: "https://gitea.example.com/acme/repo/pulls/7" });
}
throw new TeaCommandError({
args: ["pr", "list"],
cwd: repoDir,
exitCode: 1,
stderr: "request timed out",
});
};
const fresh = await getPullRequestStatus(repoDir, service);
await sleep(80);
const stale = await getPullRequestStatus(repoDir, service);
expect(stale).toEqual(fresh);
expect(stale.status?.url).toContain("/pulls/7");
expect(callCount).toBe(2);
} finally {
__resetPullRequestStatusCacheForTests();
}
});
it("does not use stale PR status fallback for forced GitHub errors", async () => {
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], {
+88 -47
View File
@@ -7,7 +7,7 @@ import type { Logger } from "pino";
import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js";
import { parseGitHubRepoFromRemote } from "../server/workspace-git-metadata.js";
import { GitHubCommandError, createGitHubService } from "../services/github-service.js";
import { createGitHubService } from "../services/github-service.js";
import type {
CurrentPullRequestStatus,
ForgeAuthState,
@@ -15,13 +15,18 @@ import type {
ForgeSpecificStatusFacts,
PullRequestMergeable,
} from "../services/forge-service.js";
import { ForgeAuthenticationError, ForgeCliMissingError } from "../services/forge-cli-command.js";
import {
ForgeAuthenticationError,
ForgeCliMissingError,
ForgeCommandError,
} from "../services/forge-cli-command.js";
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
import { runGitCommand } from "./run-git-command.js";
import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktree.js";
import { type PaseoWorktreeMetadata, readPaseoWorktreeMetadata } from "./worktree-metadata.js";
const READ_ONLY_GIT_ENV = {
GIT_OPTIONAL_LOCKS: "0",
LC_ALL: "C",
} as const;
/**
@@ -66,6 +71,7 @@ interface CheckoutReadCacheOptions {
interface PullRequestStatusLookupTarget {
headRef: string;
headSha?: string;
headRepositoryOwner?: string;
}
@@ -115,8 +121,8 @@ function createShortstatCache(ttlMs: number) {
});
}
function getPullRequestStatusCacheKey(cwd: string): string {
return resolve(cwd);
function getPullRequestStatusCacheKey(cwd: string, headSha: string | null): string {
return `${resolve(cwd)}\u0000${headSha ?? ""}`;
}
function rememberPullRequestStatus(cacheKey: string, status: PullRequestStatusResult): void {
@@ -779,7 +785,7 @@ export interface MergeFromBaseOptions {
export interface CheckoutContext {
paseoHome?: string;
worktreesRoot?: string;
logger?: Pick<Logger, "trace">;
logger?: Pick<Logger, "trace" | "warn">;
facts?: CheckoutSnapshotFacts | null;
}
@@ -804,13 +810,6 @@ export type CheckoutSnapshotFacts =
pullRequestLookupTarget: PullRequestStatusLookupTarget | null;
};
function isGitError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
return /not a git repository/i.test(error.message) || /git repository/i.test(error.message);
}
async function requireGitRepo(cwd: string): Promise<void> {
try {
await runGitCommand(["rev-parse", "--git-dir"], { cwd, envOverlay: READ_ONLY_GIT_ENV });
@@ -835,6 +834,38 @@ export async function getCurrentBranch(cwd: string): Promise<string | null> {
}
}
async function getCurrentHeadSha(cwd: string, context?: CheckoutContext): Promise<string | null> {
const knownSha = context?.facts?.isGit
? context.facts.pullRequestLookupTarget?.headSha
: undefined;
if (knownSha) {
return knownSha;
}
try {
const { stdout } = await runGitCommand(["rev-parse", "HEAD"], {
cwd,
envOverlay: READ_ONLY_GIT_ENV,
logger: context?.logger,
});
const sha = stdout.trim();
return sha.length > 0 ? sha : null;
} catch {
return null;
}
}
async function addHeadShaToPullRequestLookupTarget(
cwd: string,
target: PullRequestStatusLookupTarget | null,
context?: CheckoutContext,
): Promise<PullRequestStatusLookupTarget | null> {
if (!target) {
return null;
}
const headSha = await getCurrentHeadSha(cwd, context);
return headSha ? { ...target, headSha } : target;
}
async function getRebaseHeadBranch(cwd: string): Promise<string | null> {
const paths = ["rebase-merge/head-name", "rebase-apply/head-name"];
const results = await Promise.all(
@@ -865,7 +896,12 @@ async function getWorktreeRoot(cwd: string, context?: CheckoutContext): Promise<
logger: context?.logger,
});
return parseGitRevParsePath(stdout);
} catch {
} catch (error) {
// Git discovery is fail-open: keep the directory usable as non-Git and retain the diagnostic.
context?.logger?.warn(
{ err: error, cwd },
"Git worktree discovery failed; treating directory as non-Git",
);
return null;
}
}
@@ -1031,7 +1067,7 @@ async function getPaseoWorktreeForCwd(
return {
isPaseoOwnedWorktree: true,
worktreeRoot: knownWorktreeRoot ?? (await getWorktreeRoot(cwd)) ?? cwd,
worktreeRoot: knownWorktreeRoot ?? (await getWorktreeRoot(cwd, context)) ?? cwd,
};
}
@@ -1533,35 +1569,29 @@ async function inspectCheckoutContext(
cwd: string,
context?: CheckoutContext,
): Promise<CheckoutInspectionContext | null> {
try {
const root = await getWorktreeRoot(cwd, context);
if (!root) {
return null;
}
const [currentBranch, remoteUrl, absoluteGitDir, gitCommonDir, paseoWorktree] =
await Promise.all([
getCurrentBranch(cwd),
getOriginRemoteUrl(cwd),
resolveAbsoluteGitDir(cwd),
resolveGitCommonDir(cwd),
getPaseoWorktreeForCwd(cwd, context, root),
]);
return {
worktreeRoot: root,
currentBranch,
remoteUrl,
absoluteGitDir,
gitCommonDir,
paseoWorktree,
};
} catch (error) {
if (isGitError(error)) {
return null;
}
throw error;
const root = await getWorktreeRoot(cwd, context);
if (!root) {
return null;
}
const [currentBranch, remoteUrl, absoluteGitDir, gitCommonDir, paseoWorktree] = await Promise.all(
[
getCurrentBranch(cwd),
getOriginRemoteUrl(cwd),
resolveAbsoluteGitDir(cwd),
resolveGitCommonDir(cwd),
getPaseoWorktreeForCwd(cwd, context, root),
],
);
return {
worktreeRoot: root,
currentBranch,
remoteUrl,
absoluteGitDir,
gitCommonDir,
paseoWorktree,
};
}
function buildPullRequestLookupTargetFromBranchConfig(
@@ -1768,6 +1798,11 @@ export async function getCheckoutSnapshotFacts(
context,
)) ?? pullRequestLookupTarget;
}
pullRequestLookupTarget = await addHeadShaToPullRequestLookupTarget(
cwd,
pullRequestLookupTarget,
context,
);
return {
isGit: true,
@@ -2974,7 +3009,7 @@ export async function mergeToBase(
}
let normalizedBaseRef = baseRef;
normalizedBaseRef = normalizeLocalBranchRefName(normalizedBaseRef);
const currentWorktreeRoot = (await getWorktreeRoot(cwd)) ?? cwd;
const currentWorktreeRoot = (await getWorktreeRoot(cwd, context)) ?? cwd;
if (normalizedBaseRef === currentBranch) {
return currentWorktreeRoot;
}
@@ -3409,7 +3444,8 @@ export async function getPullRequestStatus(
options?: CheckoutReadCacheOptions,
context?: CheckoutContext,
): Promise<PullRequestStatusResult> {
const cacheKey = getPullRequestStatusCacheKey(cwd);
const headSha = await getCurrentHeadSha(cwd, context);
const cacheKey = getPullRequestStatusCacheKey(cwd, headSha);
if (!options?.force) {
const cached = pullRequestStatusCache.get(cacheKey);
if (cached) {
@@ -3422,14 +3458,14 @@ export async function getPullRequestStatus(
}
}
const lookup = getPullRequestStatusUncached(cwd, forgeService, options, context)
const lookup = getPullRequestStatusUncached(cwd, forgeService, options, context, headSha)
.then((status) => {
pullRequestStatusCache.set(cacheKey, status);
rememberPullRequestStatus(cacheKey, status);
return status;
})
.catch((error) => {
if (!options?.force && error instanceof GitHubCommandError) {
if (!options?.force && error instanceof ForgeCommandError) {
const stale = lastSuccessfulPullRequestStatus.get(cacheKey);
if (stale) {
return stale;
@@ -3450,6 +3486,7 @@ async function getPullRequestStatusUncached(
forgeService: ForgeService,
options?: CheckoutReadCacheOptions,
context?: CheckoutContext,
headSha?: string | null,
): Promise<PullRequestStatusResult> {
if (context?.facts?.isGit === false) {
return buildPullRequestStatusResult(null, "no_remote");
@@ -3462,7 +3499,11 @@ async function getPullRequestStatusUncached(
return buildPullRequestStatusResult(null, "no_remote");
}
try {
const lookupTarget = await resolvePullRequestStatusLookupTarget(cwd, head, context);
const resolvedLookupTarget = await resolvePullRequestStatusLookupTarget(cwd, head, context);
const lookupTarget =
headSha && !resolvedLookupTarget.headSha
? { ...resolvedLookupTarget, headSha }
: resolvedLookupTarget;
let status: CurrentPullRequestStatus | null;
if (options?.force) {
const reason = options.reason;
+28 -1
View File
@@ -1,6 +1,14 @@
import { mkdtempSync, mkdirSync, rmSync, symlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
import { areEquivalentPaths, createPathEquivalenceMatcher, isPathInsideRoot } from "./path.js";
import {
areEquivalentPaths,
createPathEquivalenceMatcher,
getRealpathAwareRelativePath,
isPathInsideRoot,
} from "./path.js";
describe("path equivalence", () => {
test.each([
@@ -33,4 +41,23 @@ describe("path equivalence", () => {
false,
);
});
test.skipIf(process.platform === "win32")(
"derives the contained suffix from a realpath-equivalent root",
() => {
const tempDir = mkdtempSync(join(tmpdir(), "paseo-path-"));
try {
const realRoot = join(tempDir, "real-root");
const nestedPath = join(realRoot, "packages", "app");
const aliasRoot = join(tempDir, "root-alias");
mkdirSync(nestedPath, { recursive: true });
symlinkSync(realRoot, aliasRoot, "dir");
expect(getRealpathAwareRelativePath(aliasRoot, nestedPath)).toBe(join("packages", "app"));
expect(getRealpathAwareRelativePath(aliasRoot, tempDir)).toBeNull();
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
},
);
});
+32 -10
View File
@@ -59,22 +59,44 @@ export function createRealpathAwarePathMatcher(target: string): (candidate: stri
}
export function isPathInsideRoot(root: string, candidate: string): boolean {
return getRelativePathInsideRoot(root, candidate) !== null;
}
/**
* Returns the candidate's relative suffix when it is inside root.
*
* The suffix is derived from the same lexical path pair used to prove
* containment. Callers that map an existing filesystem path into a new root
* must keep those two operations coupled.
*/
export function getRealpathAwareRelativePath(root: string, candidate: string): string | null {
const rootVariants = collectPathVariants(root);
const candidateVariants = collectPathVariants(candidate);
for (const rootVariant of rootVariants) {
for (const candidateVariant of candidateVariants) {
const relativePath = getRelativePathInsideRoot(rootVariant, candidateVariant);
if (relativePath !== null) return relativePath;
}
}
return null;
}
export function isRealpathInsideRoot(root: string, candidate: string): boolean {
return getRealpathAwareRelativePath(root, candidate) !== null;
}
function getRelativePathInsideRoot(root: string, candidate: string): string | null {
const compareAsWindows = shouldCompareAsWindows(root, candidate);
const platformPath = compareAsWindows ? nodePath.win32 : nodePath.posix;
const normalizedRoot = normalizePathForComparison(root, compareAsWindows);
const normalizedCandidate = normalizePathForComparison(candidate, compareAsWindows);
const relative = platformPath.relative(normalizedRoot, normalizedCandidate);
return relative === "" || (!relative.startsWith("..") && !platformPath.isAbsolute(relative));
}
export function isRealpathInsideRoot(root: string, candidate: string): boolean {
const rootVariants = collectPathVariants(root);
const candidateVariants = collectPathVariants(candidate);
return rootVariants.some((rootVariant) =>
candidateVariants.some((candidateVariant) => isPathInsideRoot(rootVariant, candidateVariant)),
);
return relative === "" || (!relative.startsWith("..") && !platformPath.isAbsolute(relative))
? relative
: null;
}
function collectPathVariants(value: string): string[] {
+83 -1
View File
@@ -4,14 +4,24 @@ import {
deriveWorktreeProjectHash,
deletePaseoWorktree,
isPaseoOwnedWorktreeCwd,
mapWorkspaceCwdToWorktree,
slugify,
type CreateWorktreeOptions,
type WorktreeConfig,
} from "./worktree";
import { execFileSync } from "child_process";
import { mkdtempSync, mkdirSync, rmSync, existsSync, realpathSync, writeFileSync } from "fs";
import {
mkdtempSync,
mkdirSync,
rmSync,
existsSync,
realpathSync,
symlinkSync,
writeFileSync,
} from "fs";
import { join } from "path";
import { tmpdir } from "os";
import { createRealpathAwarePathMatcher } from "./path";
interface LegacyCreateWorktreeTestOptions {
branchName: string;
@@ -86,6 +96,12 @@ describe("paseo worktree manager", () => {
const ownership = await isPaseoOwnedWorktreeCwd(created.worktreePath, { paseoHome });
expect(ownership.allowed).toBe(true);
await expect(
isPaseoOwnedWorktreeCwd(join(created.worktreePath, "packages", "app"), { paseoHome }),
).resolves.toMatchObject({
allowed: true,
worktreePath: created.worktreePath,
});
});
it("rejects paths that are not under the paseo worktrees root", async () => {
@@ -97,6 +113,72 @@ describe("paseo worktree manager", () => {
expect(ownership.allowed).toBe(false);
});
it("reports the source checkout root separately from Git's common directory", async () => {
const created = await createLegacyWorktreeForTest({
branchName: "placement-root-branch",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "placement-root",
paseoHome,
});
const ownership = await isPaseoOwnedWorktreeCwd(created.worktreePath, { paseoHome });
expect(ownership.allowed).toBe(true);
expect(createRealpathAwarePathMatcher(repoDir)(ownership.repoRoot ?? "")).toBe(true);
expect(createRealpathAwarePathMatcher(created.worktreePath)(ownership.worktreePath ?? "")).toBe(
true,
);
});
it("maps only root-contained workspace paths into a replacement worktree", () => {
const sourceWorktreePath = join(tempDir, "source-worktree");
const targetWorktreePath = join(tempDir, "target-worktree");
const nestedWorkspaceCwd = join(sourceWorktreePath, "packages", "app");
expect(
mapWorkspaceCwdToWorktree({
sourceWorktreePath,
workspaceCwd: sourceWorktreePath,
targetWorktreePath,
}),
).toBe(targetWorktreePath);
expect(
mapWorkspaceCwdToWorktree({
sourceWorktreePath,
workspaceCwd: nestedWorkspaceCwd,
targetWorktreePath,
}),
).toBe(join(targetWorktreePath, "packages", "app"));
expect(() =>
mapWorkspaceCwdToWorktree({
sourceWorktreePath,
workspaceCwd: join(tempDir, "outside-worktree"),
targetWorktreePath,
}),
).toThrow("outside its source worktree");
});
it.skipIf(process.platform === "win32")(
"maps a realpath-equivalent source workspace into the matching target subdirectory",
() => {
const sourceWorktreePath = join(tempDir, "source-worktree");
const workspaceCwd = join(sourceWorktreePath, "packages", "app");
const sourceAlias = join(tempDir, "source-alias");
const targetWorktreePath = join(tempDir, "target-worktree");
mkdirSync(workspaceCwd, { recursive: true });
symlinkSync(sourceWorktreePath, sourceAlias, "dir");
expect(
mapWorkspaceCwdToWorktree({
sourceWorktreePath: sourceAlias,
workspaceCwd,
targetWorktreePath,
}),
).toBe(join(targetWorktreePath, "packages", "app"));
},
);
it("rejects the worktrees root itself and the per-repo hash dir", async () => {
const projectHash = await deriveWorktreeProjectHash(repoDir);
const worktreesRoot = join(paseoHome, "worktrees");
+108 -95
View File
@@ -35,7 +35,7 @@ import { resolvePaseoHome } from "../server/paseo-home.js";
import { createExternalProcessEnv } from "../server/paseo-env.js";
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
import { validateBranchSlug } from "@getpaseo/protocol/branch-slug";
import { expandTilde } from "./path.js";
import { expandTilde, getRealpathAwareRelativePath, isPathInsideRoot } from "./path.js";
export { slugify, validateBranchSlug } from "@getpaseo/protocol/branch-slug";
@@ -724,11 +724,15 @@ export async function resolveWorktreeRuntimeEnv(options: {
export async function runWorktreeTeardownCommands(options: {
worktreePath: string;
teardownCwd?: string;
branchName?: string;
repoRootPath?: string;
}): Promise<WorktreeTeardownCommandResult[]> {
// Read paseo.json from the worktree (it will have the same content as the source repo)
const teardownCommands = getWorktreeTeardownCommands(options.worktreePath);
const teardownCwd = options.teardownCwd ?? options.worktreePath;
if (getRealpathAwareRelativePath(options.worktreePath, teardownCwd) === null) {
throw new Error(`Worktree teardown cwd is outside the worktree: ${teardownCwd}`);
}
const teardownCommands = getWorktreeTeardownCommands(teardownCwd);
if (teardownCommands.length === 0) {
return [];
}
@@ -756,7 +760,7 @@ export async function runWorktreeTeardownCommands(options: {
const results: WorktreeTeardownCommandResult[] = [];
for (const cmd of teardownCommands) {
const result = await execSetupCommand(cmd, {
cwd: options.worktreePath,
cwd: teardownCwd,
env: teardownEnv,
});
results.push(result);
@@ -772,6 +776,23 @@ export async function runWorktreeTeardownCommands(options: {
return results;
}
export async function seedPaseoConfigFile(options: {
sourceCwd: string;
targetCwd: string;
}): Promise<void> {
const sourceConfigPath = join(options.sourceCwd, "paseo.json");
const targetConfigPath = join(options.targetCwd, "paseo.json");
try {
await stat(targetConfigPath);
return;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
await copyFile(sourceConfigPath, targetConfigPath).catch((error) => {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
});
}
/**
* Get the git common directory (shared across worktrees) for a given cwd.
* This is where refs, objects, etc. are stored.
@@ -845,6 +866,36 @@ export async function computeWorktreePath(
return join(projectWorktreesRoot, slug);
}
export function mapWorkspaceCwdToWorktree(input: {
sourceWorktreePath: string;
workspaceCwd: string;
targetWorktreePath: string;
}): string {
const relativeWorkspaceCwd = getRealpathAwareRelativePath(
input.sourceWorktreePath,
input.workspaceCwd,
);
if (relativeWorkspaceCwd === null) {
throw new Error(`Workspace cwd is outside its source worktree: ${input.workspaceCwd}`);
}
return mapWorkspaceRelativeCwdToWorktree({
relativeWorkspaceCwd,
targetWorktreePath: input.targetWorktreePath,
});
}
export function mapWorkspaceRelativeCwdToWorktree(input: {
relativeWorkspaceCwd: string;
targetWorktreePath: string;
}): string {
const mappedCwd = resolve(input.targetWorktreePath, input.relativeWorkspaceCwd);
if (!isPathInsideRoot(input.targetWorktreePath, mappedCwd)) {
throw new Error(`Workspace cwd escapes its target worktree: ${input.relativeWorkspaceCwd}`);
}
return mappedCwd;
}
function normalizePathForOwnership(input: string): string {
try {
return realpathSync(input);
@@ -878,13 +929,13 @@ export async function isPaseoOwnedWorktreeCwd(
}
const worktreesBaseRoot = resolvePaseoWorktreesBaseRoot(options);
const paseoWorktreesPrefix = normalizePathForOwnership(worktreesBaseRoot) + sep;
const relativePath = getRealpathAwareRelativePath(worktreesBaseRoot, resolvedCwd);
// Ownership is defined by the path living under <worktrees-root>/<hash>/<slug>[/...].
// The <hash>/<slug> prefix is Paseo-private — nothing else writes there — so the
// path shape alone is sufficient proof of ownership, even when git has already
// forgotten about the worktree.
if (!resolvedCwd.startsWith(paseoWorktreesPrefix)) {
if (relativePath === null) {
return {
allowed: false,
...(repoRoot !== undefined ? { repoRoot } : {}),
@@ -892,8 +943,7 @@ export async function isPaseoOwnedWorktreeCwd(
};
}
const relative = resolvedCwd.slice(paseoWorktreesPrefix.length);
const parts = relative.split(sep).filter((part) => part.length > 0);
const parts = relativePath.split(sep).filter((part) => part.length > 0);
if (parts.length < 2) {
return {
allowed: false,
@@ -907,7 +957,7 @@ export async function isPaseoOwnedWorktreeCwd(
allowed: true,
...(repoRoot !== undefined ? { repoRoot } : {}),
worktreeRoot: worktreesRoot,
worktreePath: resolvedCwd,
worktreePath: join(worktreesRoot, parts[1]),
};
}
@@ -977,10 +1027,9 @@ export async function listPaseoWorktrees({
envOverlay: READ_ONLY_GIT_ENV,
});
const rootPrefix = normalizePathForOwnership(projectWorktreesRoot) + sep;
return parseWorktreeList(stdout)
.map((entry) => Object.assign({}, entry, { path: normalizePathForOwnership(entry.path) }))
.filter((entry) => entry.path.startsWith(rootPrefix))
.filter((entry) => getRealpathAwareRelativePath(projectWorktreesRoot, entry.path) !== null)
.map((entry) =>
Object.assign({}, entry, { createdAt: resolveWorktreeCreatedAtIso(entry.path) }),
);
@@ -1018,76 +1067,25 @@ export async function resolveExistingWorktreeForSlug({
};
}
export async function resolvePaseoWorktreeRootForCwd(
cwd: string,
options?: WorktreeRootOptions,
): Promise<{ repoRoot: string; worktreeRoot: string; worktreePath: string } | null> {
let gitCommonDir: string;
try {
gitCommonDir = await getGitCommonDir(cwd);
} catch {
return null;
}
const worktreesRoot = await getPaseoWorktreesRoot(
cwd,
options?.paseoHome,
options?.worktreesRoot,
);
const resolvedRoot = normalizePathForOwnership(worktreesRoot) + sep;
let worktreeRoot: string | null = null;
try {
const { stdout } = await runGitCommand(["rev-parse", "--show-toplevel"], {
cwd,
envOverlay: READ_ONLY_GIT_ENV,
});
worktreeRoot = parseGitRevParsePath(stdout);
} catch {
worktreeRoot = null;
}
if (!worktreeRoot) {
return null;
}
const resolvedWorktreeRoot = normalizePathForOwnership(worktreeRoot);
if (!resolvedWorktreeRoot.startsWith(resolvedRoot)) {
return null;
}
const knownWorktrees = await listPaseoWorktrees({
cwd,
paseoHome: options?.paseoHome,
worktreesRoot: options?.worktreesRoot,
});
const match = knownWorktrees.find((entry) => entry.path === resolvedWorktreeRoot);
if (!match) {
return null;
}
return {
repoRoot: gitCommonDir,
worktreeRoot: worktreesRoot,
worktreePath: match.path,
};
export interface DeletePaseoWorktreeOptions {
cwd: string | null;
worktreePath?: string;
teardownCwds?: string[];
worktreeSlug?: string;
worktreesRoot?: string;
paseoHome?: string;
worktreesBaseRoot?: string;
}
export async function deletePaseoWorktree({
cwd,
worktreePath,
teardownCwds,
worktreeSlug,
worktreesRoot,
paseoHome,
worktreesBaseRoot,
}: {
cwd: string | null;
worktreePath?: string;
worktreeSlug?: string;
worktreesRoot?: string;
paseoHome?: string;
worktreesBaseRoot?: string;
}): Promise<void> {
}: DeletePaseoWorktreeOptions): Promise<void> {
if (!worktreePath && !worktreeSlug) {
throw new Error("worktreePath or worktreeSlug is required");
}
@@ -1104,25 +1102,30 @@ export async function deletePaseoWorktree({
throw new Error("cwd or worktreesRoot is required to delete a Paseo worktree");
}
const resolvedRoot = normalizePathForOwnership(resolvedWorktreesRoot) + sep;
const requestedPath = worktreePath ?? join(resolvedWorktreesRoot, worktreeSlug!);
const resolvedRequested = normalizePathForOwnership(requestedPath);
const ownership = await isPaseoOwnedWorktreeCwd(requestedPath, {
paseoHome,
worktreesRoot: worktreesBaseRoot,
});
const resolvedWorktree =
(
await resolvePaseoWorktreeRootForCwd(requestedPath, {
paseoHome,
worktreesRoot: worktreesBaseRoot,
})
)?.worktreePath ?? resolvedRequested;
ownership.allowed && ownership.worktreePath ? ownership.worktreePath : resolvedRequested;
if (!resolvedWorktree.startsWith(resolvedRoot)) {
const relativeWorktreePath = getRealpathAwareRelativePath(
resolvedWorktreesRoot,
resolvedWorktree,
);
if (relativeWorktreePath === null || relativeWorktreePath === "") {
throw new Error("Refusing to delete non-Paseo worktree");
}
if (await pathExists(resolvedWorktree)) {
await runWorktreeTeardownCommands({
worktreePath: resolvedWorktree,
});
for (const teardownCwd of teardownCwds ?? [resolvedWorktree]) {
await runWorktreeTeardownCommands({
worktreePath: resolvedWorktree,
teardownCwd,
});
}
}
if (cwd) {
@@ -1150,6 +1153,27 @@ export async function deletePaseoWorktree({
}
}
export async function rollbackCreatedPaseoWorktree(
options: DeletePaseoWorktreeOptions,
cause: unknown,
): Promise<never> {
let cleanupError: unknown;
try {
await deletePaseoWorktree(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;
}
async function pathExists(path: string): Promise<boolean> {
try {
await stat(path);
@@ -1244,18 +1268,7 @@ export const createWorktree = async ({
: {}),
});
// If paseo.json exists in the main repo but wasn't checked into the worktree
// (e.g. uncommitted on first-time setup), seed the worktree with it so setup
// commands and scripts pick up the user's intended config.
const mainConfigPath = join(cwd, "paseo.json");
const worktreeConfigPath = join(worktreePath, "paseo.json");
try {
await stat(worktreeConfigPath);
} catch {
await copyFile(mainConfigPath, worktreeConfigPath).catch((err) => {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
});
}
await seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath });
if (runSetup) {
await runWorktreeSetupCommands({