mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 18:35:17 +00:00
Merge main into project migration branch
This commit is contained in:
@@ -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"], {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user