mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 11:42:52 +00:00
fix(server): keep Git observation and worktree IO off the event loop (#7943)
* fix(server): keep Git observation and worktree IO off the event loop Resolve subscription path identities before callbacks and serialize async worktree metadata mutations by canonical ownership. Git command tracing must not probe the working directory before spawning. * test(server): verify Git trace records without filesystem probes * fix(server): establish Git identities at observation setup Read-only checkout refreshes should not resolve watcher namespaces. Observation tests await setup and compare cross-workspace fan-out without depending on completion order. * fix(server): retain literal symlink targets in Git event identity Canonical realpaths can erase a symlink target spelling when its parent is also a symlink, such as /var on macOS. Resolve these aliases once during setup so deleted and ignored event paths still match without per-event IO. * fix(server): preserve Windows worktree path spelling * fix(server): retain native aliases in worktree ownership * test(server): write Git IO trace markers asynchronously
This commit is contained in:
@@ -172,7 +172,7 @@ describe("listCheckoutCommits", () => {
|
||||
mkdirSync(join(worktreesRoot, "repo-hash"), { recursive: true });
|
||||
git(["worktree", "add", "-b", "feature", worktreeDir], repoDir);
|
||||
commitFile(worktreeDir, "feature.txt", "feature\n", "Feature work");
|
||||
writePaseoWorktreeMetadata(worktreeDir, { baseRefName: "deleted-base" });
|
||||
await writePaseoWorktreeMetadata(worktreeDir, { baseRefName: "deleted-base" });
|
||||
|
||||
const { baseRef, commits } = await listCheckoutCommits({
|
||||
cwd: worktreeDir,
|
||||
|
||||
@@ -2859,7 +2859,7 @@ const x = 1;
|
||||
execFileSync("git", ["worktree", "add", workspaceDir, "contributor/old-change"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
writePaseoWorktreeMetadata(workspaceDir, {
|
||||
await writePaseoWorktreeMetadata(workspaceDir, {
|
||||
baseRefName: "main",
|
||||
changeRequestLookupTarget: {
|
||||
headRef: "old-change",
|
||||
@@ -2899,7 +2899,7 @@ const x = 1;
|
||||
const workspaceDir = join(paseoHome, "worktrees", "repo", "renamed-by-agent");
|
||||
mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true });
|
||||
execFileSync("git", ["worktree", "add", workspaceDir, "placeholder"], { cwd: repoDir });
|
||||
writePaseoWorktreeMetadata(workspaceDir, {
|
||||
await writePaseoWorktreeMetadata(workspaceDir, {
|
||||
baseRefName: "main",
|
||||
changeRequestLookupTarget: {
|
||||
headRef: "placeholder",
|
||||
@@ -2936,7 +2936,7 @@ const x = 1;
|
||||
const workspaceDir = join(paseoHome, "worktrees", "repo", "pinned-worktree");
|
||||
mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true });
|
||||
execFileSync("git", ["worktree", "add", workspaceDir, "feature/pinned"], { cwd: repoDir });
|
||||
writePaseoWorktreeMetadata(workspaceDir, {
|
||||
await writePaseoWorktreeMetadata(workspaceDir, {
|
||||
baseRefName: "main",
|
||||
changeRequestLookupTarget: {
|
||||
headRef: "feature/pinned",
|
||||
@@ -2975,7 +2975,7 @@ const x = 1;
|
||||
execFileSync("git", ["worktree", "add", workspaceDir, "contributor/old-change-1"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
writePaseoWorktreeMetadata(workspaceDir, {
|
||||
await writePaseoWorktreeMetadata(workspaceDir, {
|
||||
baseRefName: "main",
|
||||
changeRequestLookupTarget: {
|
||||
headRef: "old-change",
|
||||
@@ -2997,7 +2997,7 @@ const x = 1;
|
||||
execFileSync("git", ["worktree", "add", workspaceDir, "contributor/old-change"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
writePaseoWorktreeMetadata(workspaceDir, {
|
||||
await writePaseoWorktreeMetadata(workspaceDir, {
|
||||
baseRefName: "main",
|
||||
changeRequestLookupTarget: {
|
||||
headRef: "old-change",
|
||||
@@ -3062,7 +3062,7 @@ const x = 1;
|
||||
execFileSync("git", ["worktree", "add", workspaceDir, "mixedowner/old-change"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
writePaseoWorktreeMetadata(workspaceDir, {
|
||||
await writePaseoWorktreeMetadata(workspaceDir, {
|
||||
baseRefName: "main",
|
||||
changeRequestLookupTarget: {
|
||||
headRef: "old-change",
|
||||
@@ -3114,7 +3114,7 @@ const x = 1;
|
||||
execFileSync("git", ["worktree", "add", workspaceDir, "feature/gitlab-mr"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
writePaseoWorktreeMetadata(workspaceDir, {
|
||||
await writePaseoWorktreeMetadata(workspaceDir, {
|
||||
baseRefName: "main",
|
||||
changeRequestLookupTarget: {
|
||||
headRef: "feature/gitlab-mr",
|
||||
@@ -3128,7 +3128,9 @@ const x = 1;
|
||||
const forge = createGitHubServiceRecordingPullRequestTargets({ requestedTargets });
|
||||
|
||||
await renameCurrentBranch(workspaceCwd, "feature/renamed");
|
||||
expect(readPaseoWorktreeMetadata(workspaceDir)?.changeRequestLookupTarget).toMatchObject({
|
||||
expect(
|
||||
(await readPaseoWorktreeMetadata(workspaceDir))?.changeRequestLookupTarget,
|
||||
).toMatchObject({
|
||||
localBranchName: "feature/renamed",
|
||||
});
|
||||
const renamedFacts = await getCheckoutSnapshotFacts(workspaceDir, { paseoHome });
|
||||
@@ -3164,7 +3166,7 @@ const x = 1;
|
||||
execFileSync("git", ["worktree", "add", workspaceDir, "feature/placeholder"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
writePaseoWorktreeMetadata(workspaceDir, {
|
||||
await writePaseoWorktreeMetadata(workspaceDir, {
|
||||
baseRefName: "main",
|
||||
changeRequestLookupTarget: {
|
||||
headRef: "feature/placeholder",
|
||||
@@ -3174,7 +3176,7 @@ const x = 1;
|
||||
|
||||
await renameCurrentBranch(workspaceDir, "feature/generated");
|
||||
|
||||
expect(readPaseoWorktreeMetadata(workspaceDir)?.changeRequestLookupTarget).toEqual({
|
||||
expect((await readPaseoWorktreeMetadata(workspaceDir))?.changeRequestLookupTarget).toEqual({
|
||||
headRef: "feature/generated",
|
||||
localBranchName: "feature/generated",
|
||||
});
|
||||
@@ -3951,7 +3953,7 @@ const x = 1;
|
||||
cwd: worktree.worktreePath,
|
||||
});
|
||||
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(worktree.worktreePath);
|
||||
const metadataPath = await getPaseoWorktreeMetadataPath(worktree.worktreePath);
|
||||
rmSync(metadataPath, { force: true });
|
||||
|
||||
const baseDiff = await getCheckoutDiff(worktree.worktreePath, { mode: "base" }, { paseoHome });
|
||||
@@ -3970,7 +3972,7 @@ const x = 1;
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(worktree.worktreePath);
|
||||
const metadataPath = await getPaseoWorktreeMetadataPath(worktree.worktreePath);
|
||||
rmSync(metadataPath, { force: true });
|
||||
|
||||
const status = await getCheckoutStatus(worktree.worktreePath, { paseoHome });
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { MAX_RELAY_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit";
|
||||
import { resolve, dirname, basename } from "path";
|
||||
import { existsSync, realpathSync } from "fs";
|
||||
import { open as openFile, readFile, stat as statFile, mkdtemp, copyFile, rm } from "fs/promises";
|
||||
import {
|
||||
open as openFile,
|
||||
readFile,
|
||||
stat as statFile,
|
||||
mkdtemp,
|
||||
copyFile,
|
||||
realpath,
|
||||
access,
|
||||
rm,
|
||||
} from "fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import { TTLCache } from "@isaacs/ttlcache";
|
||||
@@ -1104,7 +1112,7 @@ async function getMainRepoRootFromCommonDir(
|
||||
if (!commonDir) {
|
||||
throw new Error("Not in a git repository");
|
||||
}
|
||||
const normalized = realpathSync(commonDir);
|
||||
const normalized = await realpath(commonDir);
|
||||
|
||||
if (basename(normalized) === ".git") {
|
||||
return dirname(normalized);
|
||||
@@ -1229,7 +1237,7 @@ export async function renameCurrentBranch(
|
||||
|
||||
const currentBranch = await getCurrentBranch(cwd);
|
||||
if (currentBranch) {
|
||||
rebindPaseoWorktreeChangeRequestHint(worktreeRoot, previousBranch, currentBranch);
|
||||
await rebindPaseoWorktreeChangeRequestHint(worktreeRoot, previousBranch, currentBranch);
|
||||
}
|
||||
return { previousBranch, currentBranch };
|
||||
}
|
||||
@@ -1274,8 +1282,8 @@ function storedBaseRefFromMetadata(metadata: PaseoWorktreeMetadata | null): stri
|
||||
return metadata?.baseRef ?? metadata?.baseRefName ?? null;
|
||||
}
|
||||
|
||||
function readPaseoWorktreeBaseRef(worktreeRoot: string): string | null {
|
||||
return storedBaseRefFromMetadata(readPaseoWorktreeMetadata(worktreeRoot));
|
||||
async function readPaseoWorktreeBaseRef(worktreeRoot: string): Promise<string | null> {
|
||||
return storedBaseRefFromMetadata(await readPaseoWorktreeMetadata(worktreeRoot));
|
||||
}
|
||||
|
||||
async function getStoredBaseRefForCwd(
|
||||
@@ -1549,7 +1557,12 @@ async function abortGitPullConflictState(cwd: string): Promise<void> {
|
||||
const rebaseMergePath = resolve(gitDir, "rebase-merge");
|
||||
const rebaseApplyPath = resolve(gitDir, "rebase-apply");
|
||||
|
||||
if (existsSync(mergeHeadPath)) {
|
||||
if (
|
||||
await access(mergeHeadPath).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
) {
|
||||
try {
|
||||
await runGitCommand(["merge", "--abort"], { cwd, timeout: 120_000 });
|
||||
} catch {
|
||||
@@ -1557,7 +1570,16 @@ async function abortGitPullConflictState(cwd: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(rebaseMergePath) || existsSync(rebaseApplyPath)) {
|
||||
if (
|
||||
(await access(rebaseMergePath).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)) ||
|
||||
(await access(rebaseApplyPath).then(
|
||||
() => true,
|
||||
() => false,
|
||||
))
|
||||
) {
|
||||
try {
|
||||
await runGitCommand(["rebase", "--abort"], { cwd, timeout: 120_000 });
|
||||
} catch {
|
||||
@@ -2078,7 +2100,7 @@ export async function getCheckoutSnapshotFacts(
|
||||
}
|
||||
|
||||
const paseoWorktreeMetadata = inspected.paseoWorktree.isPaseoOwnedWorktree
|
||||
? readPaseoWorktreeMetadata(inspected.paseoWorktree.worktreeRoot)
|
||||
? await readPaseoWorktreeMetadata(inspected.paseoWorktree.worktreeRoot)
|
||||
: null;
|
||||
const storedBaseRef = storedBaseRefFromMetadata(paseoWorktreeMetadata);
|
||||
const resolvedBaseRef = storedBaseRef ?? (await resolveBaseRef(cwd, context));
|
||||
|
||||
@@ -107,7 +107,7 @@ export function isRealpathInsideRoot(root: string, candidate: string): boolean {
|
||||
return getRealpathAwareRelativePath(root, candidate) !== null;
|
||||
}
|
||||
|
||||
function getRelativePathInsideRoot(root: string, candidate: string): string | null {
|
||||
export 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);
|
||||
@@ -175,7 +175,7 @@ function normalizePathForComparison(value: string, compareAsWindows: boolean): s
|
||||
return compareAsWindows ? normalized.toLowerCase() : normalized;
|
||||
}
|
||||
|
||||
function normalizePathPreservingCase(value: string, compareAsWindows: boolean): string {
|
||||
export function normalizePathPreservingCase(value: string, compareAsWindows: boolean): string {
|
||||
const platformPath = compareAsWindows ? nodePath.win32 : nodePath.posix;
|
||||
const comparableValue = compareAsWindows ? stripWindowsNamespacePrefix(value) : value;
|
||||
const platformNormalized = platformPath.normalize(comparableValue);
|
||||
|
||||
@@ -507,7 +507,6 @@ describe("runGitCommand", () => {
|
||||
command: "git",
|
||||
args: ["status", "--short"],
|
||||
cwd: process.cwd(),
|
||||
cwdExists: true,
|
||||
timeout: 30_000,
|
||||
maxOutputBytes: 20 * 1024 * 1024,
|
||||
acceptExitCodes: [0, 1],
|
||||
@@ -520,7 +519,6 @@ describe("runGitCommand", () => {
|
||||
command: "git",
|
||||
args: ["status", "--short"],
|
||||
cwd: process.cwd(),
|
||||
cwdExists: true,
|
||||
durationMs: expect.any(Number),
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
@@ -530,6 +528,9 @@ describe("runGitCommand", () => {
|
||||
}),
|
||||
"Git command closed",
|
||||
);
|
||||
for (const [record] of trace.mock.calls) {
|
||||
expect(record).not.toHaveProperty("cwdExists");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-zero exit codes that are not accepted and frees the slot", async () => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { existsSync } from "node:fs";
|
||||
import type { Logger } from "pino";
|
||||
import type { ProcessEnvRecord } from "../server/paseo-env.js";
|
||||
import {
|
||||
@@ -311,7 +310,6 @@ function executeGitCommand<Output>(
|
||||
command: "git",
|
||||
args,
|
||||
cwd: options.cwd,
|
||||
cwdExists: existsSync(options.cwd),
|
||||
timeout,
|
||||
maxOutputBytes,
|
||||
acceptExitCodes,
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
|
||||
import { mkdir, open, realpath, rename, stat, writeFile } from "node:fs/promises";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import pLimit from "p-limit";
|
||||
import { isAbsolute, join, resolve } from "path";
|
||||
import { z } from "zod";
|
||||
|
||||
const MAX_WORKTREE_METADATA_BYTES = 64 * 1024;
|
||||
|
||||
const ChangeRequestLookupTargetSchema = z.object({
|
||||
headRef: z.string().min(1),
|
||||
headRepositoryOwner: z.string().min(1).optional(),
|
||||
@@ -90,75 +95,78 @@ function normalizeLegacyGitHubOwnerForBranch(owner: string): string | null {
|
||||
return /^[a-z0-9-]+$/.test(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
export function rebindPaseoWorktreeChangeRequestHint(
|
||||
export async function rebindPaseoWorktreeChangeRequestHint(
|
||||
worktreeRoot: string,
|
||||
previousBranch: string,
|
||||
currentBranch: string,
|
||||
): boolean {
|
||||
const metadata = readPaseoWorktreeMetadata(worktreeRoot);
|
||||
const target = getPaseoWorktreeChangeRequestHintForBranch(metadata, previousBranch);
|
||||
if (!metadata || !target) {
|
||||
return false;
|
||||
}
|
||||
): Promise<boolean> {
|
||||
return mutateMetadata(worktreeRoot, async (root) => {
|
||||
const metadata = await readMetadataFile(root);
|
||||
const target = getPaseoWorktreeChangeRequestHintForBranch(metadata, previousBranch);
|
||||
if (!metadata || !target) {
|
||||
return false;
|
||||
}
|
||||
|
||||
writePaseoWorktreeMetadataFile(worktreeRoot, {
|
||||
...metadata,
|
||||
changeRequestLookupTarget: {
|
||||
...target,
|
||||
...(target.headRef === previousBranch &&
|
||||
!target.headRepositoryOwner &&
|
||||
target.changeRequestNumber === undefined
|
||||
? { headRef: currentBranch }
|
||||
: {}),
|
||||
localBranchName: currentBranch,
|
||||
},
|
||||
await writePaseoWorktreeMetadataFile(root, {
|
||||
...metadata,
|
||||
changeRequestLookupTarget: {
|
||||
...target,
|
||||
...(target.headRef === previousBranch &&
|
||||
!target.headRepositoryOwner &&
|
||||
target.changeRequestNumber === undefined
|
||||
? { headRef: currentBranch }
|
||||
: {}),
|
||||
localBranchName: currentBranch,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export function pinPaseoWorktreeBranchIdentityIfMissing(
|
||||
export async function pinPaseoWorktreeBranchIdentityIfMissing(
|
||||
worktreeRoot: string,
|
||||
branch: string,
|
||||
): boolean {
|
||||
const metadata = readPaseoWorktreeMetadata(worktreeRoot);
|
||||
if (!metadata || metadata.changeRequestLookupTarget) {
|
||||
return false;
|
||||
}
|
||||
const target = createPaseoWorktreeChangeRequestHint({
|
||||
headRef: branch,
|
||||
localBranchName: branch,
|
||||
): Promise<boolean> {
|
||||
return mutateMetadata(worktreeRoot, async (root) => {
|
||||
const metadata = await readMetadataFile(root);
|
||||
if (!metadata || metadata.changeRequestLookupTarget) {
|
||||
return false;
|
||||
}
|
||||
const target = createPaseoWorktreeChangeRequestHint({
|
||||
headRef: branch,
|
||||
localBranchName: branch,
|
||||
});
|
||||
await writePaseoWorktreeMetadataFile(root, {
|
||||
...metadata,
|
||||
changeRequestLookupTarget: target,
|
||||
});
|
||||
return true;
|
||||
});
|
||||
writePaseoWorktreeMetadataFile(worktreeRoot, {
|
||||
...metadata,
|
||||
changeRequestLookupTarget: target,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function getGitDirForWorktreeRoot(worktreeRoot: string): string {
|
||||
async function getGitDirForWorktreeRoot(worktreeRoot: string): Promise<string> {
|
||||
const gitPath = join(worktreeRoot, ".git");
|
||||
if (!existsSync(gitPath)) {
|
||||
throw new Error(`Not a git repository: ${worktreeRoot}`);
|
||||
}
|
||||
|
||||
// In a worktree checkout, `.git` is a file containing `gitdir: <path>`.
|
||||
// In a normal checkout, `.git` is a directory.
|
||||
let info;
|
||||
try {
|
||||
const gitFileContent = readFileSync(gitPath, "utf8");
|
||||
const match = gitFileContent.match(/gitdir:\s*(.+)/);
|
||||
if (match?.[1]) {
|
||||
const raw = match[1].trim();
|
||||
return isAbsolute(raw) ? raw : resolve(worktreeRoot, raw);
|
||||
}
|
||||
} catch {
|
||||
// If `.git` is a directory, readFileSync will throw; fall through.
|
||||
info = await stat(gitPath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT")
|
||||
throw new Error(`Not a git repository: ${worktreeRoot}`, { cause: error });
|
||||
throw error;
|
||||
}
|
||||
if (info.isDirectory()) return gitPath;
|
||||
const gitFileContent = await readBoundedFile(gitPath);
|
||||
const match = gitFileContent.match(/gitdir:\s*(.+)/);
|
||||
if (match?.[1]) {
|
||||
const raw = match[1].trim();
|
||||
return isAbsolute(raw) ? raw : resolve(worktreeRoot, raw);
|
||||
}
|
||||
|
||||
return gitPath;
|
||||
}
|
||||
|
||||
export function getPaseoWorktreeMetadataPath(worktreeRoot: string): string {
|
||||
const gitDir = getGitDirForWorktreeRoot(worktreeRoot);
|
||||
export async function getPaseoWorktreeMetadataPath(worktreeRoot: string): Promise<string> {
|
||||
const gitDir = await getGitDirForWorktreeRoot(worktreeRoot);
|
||||
return join(gitDir, "paseo", "worktree.json");
|
||||
}
|
||||
|
||||
@@ -213,120 +221,166 @@ function assertValidBaseRef(value: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function writePaseoWorktreeMetadata(
|
||||
export async function writePaseoWorktreeMetadata(
|
||||
worktreeRoot: string,
|
||||
options: {
|
||||
baseRefName: string;
|
||||
baseRef?: string;
|
||||
changeRequestLookupTarget?: PaseoWorktreeChangeRequestHint;
|
||||
},
|
||||
): void {
|
||||
const baseRefName = normalizeBaseRefName(options.baseRefName);
|
||||
assertValidBaseRef(baseRefName);
|
||||
const baseRef = options.baseRef?.trim();
|
||||
if (baseRef) {
|
||||
assertValidBaseRef(baseRef);
|
||||
}
|
||||
): Promise<void> {
|
||||
return mutateMetadata(worktreeRoot, async (root) => {
|
||||
const baseRefName = normalizeBaseRefName(options.baseRefName);
|
||||
assertValidBaseRef(baseRefName);
|
||||
const baseRef = options.baseRef?.trim();
|
||||
if (baseRef) {
|
||||
assertValidBaseRef(baseRef);
|
||||
}
|
||||
|
||||
const metadata: PaseoWorktreeMetadata = {
|
||||
version: 1,
|
||||
baseRefName,
|
||||
...(baseRef ? { baseRef } : {}),
|
||||
...(options.changeRequestLookupTarget
|
||||
? { changeRequestLookupTarget: options.changeRequestLookupTarget }
|
||||
: {}),
|
||||
};
|
||||
writePaseoWorktreeMetadataFile(worktreeRoot, metadata);
|
||||
}
|
||||
|
||||
export function writePaseoWorktreeRuntimeMetadata(
|
||||
worktreeRoot: string,
|
||||
options: { worktreePort: number },
|
||||
): void {
|
||||
if (!Number.isInteger(options.worktreePort) || options.worktreePort <= 0) {
|
||||
throw new Error(`Invalid worktree runtime port: ${options.worktreePort}`);
|
||||
}
|
||||
|
||||
const current = readPaseoWorktreeMetadata(worktreeRoot);
|
||||
if (!current) {
|
||||
throw new Error("Cannot persist worktree runtime metadata: missing base metadata");
|
||||
}
|
||||
|
||||
const next: PaseoWorktreeMetadata = {
|
||||
...current,
|
||||
version: 2,
|
||||
runtime: {
|
||||
worktreePort: options.worktreePort,
|
||||
},
|
||||
};
|
||||
writePaseoWorktreeMetadataFile(worktreeRoot, next);
|
||||
}
|
||||
|
||||
export function writePaseoWorktreeFirstAgentBranchAutoNameMetadata(
|
||||
worktreeRoot: string,
|
||||
options: { placeholderBranchName: string },
|
||||
): void {
|
||||
const placeholderBranchName = options.placeholderBranchName.trim();
|
||||
if (!placeholderBranchName) {
|
||||
throw new Error("Placeholder branch name is required");
|
||||
}
|
||||
|
||||
const current = readPaseoWorktreeMetadata(worktreeRoot);
|
||||
if (!current) {
|
||||
throw new Error("Cannot persist first-agent branch auto-name metadata: missing base metadata");
|
||||
}
|
||||
|
||||
writePaseoWorktreeMetadataFile(worktreeRoot, {
|
||||
...current,
|
||||
version: 2,
|
||||
firstAgentBranchAutoName: {
|
||||
status: "pending",
|
||||
placeholderBranchName,
|
||||
},
|
||||
const metadata: PaseoWorktreeMetadata = {
|
||||
version: 1,
|
||||
baseRefName,
|
||||
...(baseRef ? { baseRef } : {}),
|
||||
...(options.changeRequestLookupTarget
|
||||
? { changeRequestLookupTarget: options.changeRequestLookupTarget }
|
||||
: {}),
|
||||
};
|
||||
await writePaseoWorktreeMetadataFile(root, metadata);
|
||||
});
|
||||
}
|
||||
|
||||
export function markPaseoWorktreeFirstAgentBranchAutoNameAttempted(
|
||||
export async function writePaseoWorktreeRuntimeMetadata(
|
||||
worktreeRoot: string,
|
||||
options: { worktreePort: number },
|
||||
): Promise<void> {
|
||||
return mutateMetadata(worktreeRoot, async (root) => {
|
||||
if (!Number.isInteger(options.worktreePort) || options.worktreePort <= 0) {
|
||||
throw new Error(`Invalid worktree runtime port: ${options.worktreePort}`);
|
||||
}
|
||||
|
||||
const current = await readMetadataFile(root);
|
||||
if (!current) {
|
||||
throw new Error("Cannot persist worktree runtime metadata: missing base metadata");
|
||||
}
|
||||
|
||||
const next: PaseoWorktreeMetadata = {
|
||||
...current,
|
||||
version: 2,
|
||||
runtime: {
|
||||
worktreePort: options.worktreePort,
|
||||
},
|
||||
};
|
||||
await writePaseoWorktreeMetadataFile(root, next);
|
||||
});
|
||||
}
|
||||
|
||||
export async function writePaseoWorktreeFirstAgentBranchAutoNameMetadata(
|
||||
worktreeRoot: string,
|
||||
options: { placeholderBranchName: string },
|
||||
): Promise<void> {
|
||||
return mutateMetadata(worktreeRoot, async (root) => {
|
||||
const placeholderBranchName = options.placeholderBranchName.trim();
|
||||
if (!placeholderBranchName) {
|
||||
throw new Error("Placeholder branch name is required");
|
||||
}
|
||||
|
||||
const current = await readMetadataFile(root);
|
||||
if (!current) {
|
||||
throw new Error(
|
||||
"Cannot persist first-agent branch auto-name metadata: missing base metadata",
|
||||
);
|
||||
}
|
||||
|
||||
await writePaseoWorktreeMetadataFile(root, {
|
||||
...current,
|
||||
version: 2,
|
||||
firstAgentBranchAutoName: {
|
||||
status: "pending",
|
||||
placeholderBranchName,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function markPaseoWorktreeFirstAgentBranchAutoNameAttempted(
|
||||
worktreeRoot: string,
|
||||
options: { attemptedAt?: string } = {},
|
||||
): PaseoWorktreeMetadata | null {
|
||||
const current = readPaseoWorktreeMetadata(worktreeRoot);
|
||||
if (!current || current.version !== 2 || current.firstAgentBranchAutoName?.status !== "pending") {
|
||||
return current;
|
||||
}
|
||||
): Promise<PaseoWorktreeMetadata | null> {
|
||||
return mutateMetadata(worktreeRoot, async (root) => {
|
||||
const current = await readMetadataFile(root);
|
||||
if (
|
||||
!current ||
|
||||
current.version !== 2 ||
|
||||
current.firstAgentBranchAutoName?.status !== "pending"
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const next: PaseoWorktreeMetadata = {
|
||||
...current,
|
||||
firstAgentBranchAutoName: {
|
||||
status: "attempted",
|
||||
placeholderBranchName: current.firstAgentBranchAutoName.placeholderBranchName,
|
||||
attemptedAt: options.attemptedAt ?? new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
writePaseoWorktreeMetadataFile(worktreeRoot, next);
|
||||
return next;
|
||||
const next: PaseoWorktreeMetadata = {
|
||||
...current,
|
||||
firstAgentBranchAutoName: {
|
||||
status: "attempted",
|
||||
placeholderBranchName: current.firstAgentBranchAutoName.placeholderBranchName,
|
||||
attemptedAt: options.attemptedAt ?? new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
await writePaseoWorktreeMetadataFile(root, next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
export function readPaseoWorktreeMetadata(worktreeRoot: string): PaseoWorktreeMetadata | null {
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot);
|
||||
if (!existsSync(metadataPath)) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(readFileSync(metadataPath, "utf8"));
|
||||
return PaseoWorktreeMetadataSchema.parse(parsed);
|
||||
export async function readPaseoWorktreeMetadata(
|
||||
worktreeRoot: string,
|
||||
): Promise<PaseoWorktreeMetadata | null> {
|
||||
const key = await resolveMetadataIdentity(worktreeRoot);
|
||||
await metadataMutations.get(key)?.catch(() => {});
|
||||
return metadataIo(() => readMetadataFile(key));
|
||||
}
|
||||
|
||||
export function requirePaseoWorktreeBaseRefName(worktreeRoot: string): string {
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot);
|
||||
const metadata = readPaseoWorktreeMetadata(worktreeRoot);
|
||||
async function readMetadataFile(worktreeRoot: string): Promise<PaseoWorktreeMetadata | null> {
|
||||
const metadataPath = await getPaseoWorktreeMetadataPath(worktreeRoot);
|
||||
try {
|
||||
return PaseoWorktreeMetadataSchema.parse(JSON.parse(await readBoundedFile(metadataPath)));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// O_NONBLOCK lets us reject FIFOs by kind without hanging in open(). A fixed read also
|
||||
// caps a regular file that grows between stat and read; readFile's whole-file loop does not.
|
||||
async function readBoundedFile(path: string): Promise<string> {
|
||||
const file = await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
|
||||
try {
|
||||
const info = await file.stat();
|
||||
const limit = MAX_WORKTREE_METADATA_BYTES;
|
||||
if (!info.isFile() || info.size > limit)
|
||||
throw new Error(`Invalid Paseo worktree metadata file: ${path}`);
|
||||
const buffer = Buffer.alloc(limit + 1);
|
||||
let length = 0;
|
||||
while (length < buffer.length) {
|
||||
const { bytesRead } = await file.read(buffer, length, buffer.length - length);
|
||||
if (bytesRead === 0) break;
|
||||
length += bytesRead;
|
||||
}
|
||||
if (length > limit) throw new Error(`Invalid Paseo worktree metadata file: ${path}`);
|
||||
return buffer.toString("utf8", 0, length);
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function requirePaseoWorktreeBaseRefName(worktreeRoot: string): Promise<string> {
|
||||
const metadataPath = await getPaseoWorktreeMetadataPath(worktreeRoot);
|
||||
const metadata = await readPaseoWorktreeMetadata(worktreeRoot);
|
||||
if (!metadata) {
|
||||
throw new Error(`Missing Paseo worktree base metadata: ${metadataPath}`);
|
||||
}
|
||||
return metadata.baseRefName;
|
||||
}
|
||||
|
||||
export function readPaseoWorktreeRuntimePort(worktreeRoot: string): number | null {
|
||||
const metadata = readPaseoWorktreeMetadata(worktreeRoot);
|
||||
export async function readPaseoWorktreeRuntimePort(worktreeRoot: string): Promise<number | null> {
|
||||
const metadata = await readPaseoWorktreeMetadata(worktreeRoot);
|
||||
if (!metadata) {
|
||||
return null;
|
||||
}
|
||||
@@ -336,13 +390,61 @@ export function readPaseoWorktreeRuntimePort(worktreeRoot: string): number | nul
|
||||
return null;
|
||||
}
|
||||
|
||||
function writePaseoWorktreeMetadataFile(
|
||||
async function writePaseoWorktreeMetadataFile(
|
||||
worktreeRoot: string,
|
||||
metadata: PaseoWorktreeMetadata,
|
||||
): void {
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot);
|
||||
mkdirSync(join(getGitDirForWorktreeRoot(worktreeRoot), "paseo"), { recursive: true });
|
||||
const tempPath = `${metadataPath}.${process.pid}.${Date.now()}.tmp`;
|
||||
writeFileSync(tempPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
|
||||
renameSync(tempPath, metadataPath);
|
||||
): Promise<void> {
|
||||
const metadataPath = await getPaseoWorktreeMetadataPath(worktreeRoot);
|
||||
const contents = `${JSON.stringify(metadata, null, 2)}\n`;
|
||||
if (Buffer.byteLength(contents) > MAX_WORKTREE_METADATA_BYTES) {
|
||||
throw new Error(`Invalid Paseo worktree metadata file: ${metadataPath}`);
|
||||
}
|
||||
// Never recreate an archived Git directory while a queued mutation is pending.
|
||||
try {
|
||||
await mkdir(join(await getGitDirForWorktreeRoot(worktreeRoot), "paseo"));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
||||
}
|
||||
const tempPath = `${metadataPath}.${randomUUID()}.tmp`;
|
||||
await writeFile(tempPath, contents, "utf8");
|
||||
await rename(tempPath, metadataPath);
|
||||
}
|
||||
|
||||
const metadataIo = pLimit(2);
|
||||
const metadataMutations = new Map<string, Promise<unknown>>();
|
||||
|
||||
// Only share in-flight identity resolution: a replaced symlink must not retain an old owner.
|
||||
const metadataIdentities = new Map<string, Promise<string>>();
|
||||
|
||||
function resolveMetadataIdentity(worktreeRoot: string): Promise<string> {
|
||||
const path = resolve(worktreeRoot);
|
||||
const existing = metadataIdentities.get(path);
|
||||
if (existing) return existing;
|
||||
const identity = metadataIo(async () => {
|
||||
try {
|
||||
return await realpath(path);
|
||||
} catch {
|
||||
return path;
|
||||
}
|
||||
}).finally(() => {
|
||||
if (metadataIdentities.get(path) === identity) metadataIdentities.delete(path);
|
||||
});
|
||||
metadataIdentities.set(path, identity);
|
||||
return identity;
|
||||
}
|
||||
|
||||
async function mutateMetadata<T>(
|
||||
worktreeRoot: string,
|
||||
mutation: (root: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const key = await resolveMetadataIdentity(worktreeRoot);
|
||||
const previous = metadataMutations.get(key) ?? Promise.resolve();
|
||||
const pending = previous.catch(() => {}).then(() => metadataIo(() => mutation(key)));
|
||||
metadataMutations.set(key, pending);
|
||||
void pending
|
||||
.finally(() => {
|
||||
if (metadataMutations.get(key) === pending) metadataMutations.delete(key);
|
||||
})
|
||||
.catch(() => {});
|
||||
return pending;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,13 @@ import {
|
||||
type WorktreeConfig,
|
||||
} from "./worktree";
|
||||
import type { PaseoConfig } from "@getpaseo/protocol/paseo-config-schema";
|
||||
import { getPaseoWorktreeMetadataPath, readPaseoWorktreeMetadata } from "./worktree-metadata.js";
|
||||
import {
|
||||
getPaseoWorktreeMetadataPath,
|
||||
readPaseoWorktreeMetadata,
|
||||
writePaseoWorktreeMetadata,
|
||||
writePaseoWorktreeRuntimeMetadata,
|
||||
writePaseoWorktreeFirstAgentBranchAutoNameMetadata,
|
||||
} from "./worktree-metadata.js";
|
||||
import {
|
||||
getCheckoutDiff,
|
||||
getCheckoutStatus,
|
||||
@@ -125,7 +131,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
expect(result.worktreePath).toBe(join(paseoHome, "worktrees", projectHash, "hello-world"));
|
||||
expect(existsSync(result.worktreePath)).toBe(true);
|
||||
expect(existsSync(join(result.worktreePath, "file.txt"))).toBe(true);
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
|
||||
const metadataPath = await getPaseoWorktreeMetadataPath(result.worktreePath);
|
||||
expect(existsSync(metadataPath)).toBe(true);
|
||||
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
|
||||
expect(metadata).toMatchObject({
|
||||
@@ -256,7 +262,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
cwd: result.worktreePath,
|
||||
});
|
||||
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
|
||||
const metadataPath = await getPaseoWorktreeMetadataPath(result.worktreePath);
|
||||
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
|
||||
expect(metadata).toMatchObject({
|
||||
version: 1,
|
||||
@@ -319,7 +325,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
// baseRefName must be the repo's default branch, not the checked-out branch
|
||||
// itself — otherwise the diff panel compares the branch against itself and
|
||||
// always shows no changes.
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
|
||||
const metadataPath = await getPaseoWorktreeMetadataPath(result.worktreePath);
|
||||
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
|
||||
expect(metadata).toMatchObject({
|
||||
version: 1,
|
||||
@@ -370,7 +376,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
});
|
||||
|
||||
expect(existsSync(result.worktreePath)).toBe(true);
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
|
||||
const metadataPath = await getPaseoWorktreeMetadataPath(result.worktreePath);
|
||||
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
|
||||
expect(metadata).toMatchObject({ version: 1, baseRefName: "develop" });
|
||||
});
|
||||
@@ -406,7 +412,9 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
|
||||
expect(result.branchName).toBe("main-1");
|
||||
expect(existsSync(result.worktreePath)).toBe(true);
|
||||
expect(readPaseoWorktreeMetadata(result.worktreePath)?.changeRequestLookupTarget).toEqual({
|
||||
expect(
|
||||
(await readPaseoWorktreeMetadata(result.worktreePath))?.changeRequestLookupTarget,
|
||||
).toEqual({
|
||||
headRef: "main-1",
|
||||
localBranchName: "main-1",
|
||||
});
|
||||
@@ -459,7 +467,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
.trim();
|
||||
expect(currentBranch).toBe("user/feature");
|
||||
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
|
||||
const metadataPath = await getPaseoWorktreeMetadataPath(result.worktreePath);
|
||||
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
|
||||
expect(metadata).toMatchObject({ baseRefName: "main" });
|
||||
});
|
||||
@@ -566,7 +574,9 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
"from-origin\n",
|
||||
);
|
||||
expect(
|
||||
JSON.parse(readFileSync(getPaseoWorktreeMetadataPath(originResult.worktreePath), "utf8")),
|
||||
JSON.parse(
|
||||
readFileSync(await getPaseoWorktreeMetadataPath(originResult.worktreePath), "utf8"),
|
||||
),
|
||||
).toMatchObject({ baseRefName: "main" });
|
||||
});
|
||||
|
||||
@@ -607,7 +617,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
// to the commit the worktree was cut from: "main" resolves local-first, which here is
|
||||
// a different commit than the fork's upstream.
|
||||
expect(
|
||||
JSON.parse(readFileSync(getPaseoWorktreeMetadataPath(result.worktreePath), "utf8")),
|
||||
JSON.parse(readFileSync(await getPaseoWorktreeMetadataPath(result.worktreePath), "utf8")),
|
||||
).toMatchObject({ baseRefName: "main", baseRef: "refs/remotes/upstream/main" });
|
||||
|
||||
// An untouched child must report no work of its own. Comparing against the wrong base
|
||||
@@ -694,7 +704,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
});
|
||||
|
||||
expect(
|
||||
JSON.parse(readFileSync(getPaseoWorktreeMetadataPath(result.worktreePath), "utf8")),
|
||||
JSON.parse(readFileSync(await getPaseoWorktreeMetadataPath(result.worktreePath), "utf8")),
|
||||
).toMatchObject({
|
||||
baseRefName: "release+hotfix",
|
||||
baseRef: "refs/remotes/upstream/release+hotfix",
|
||||
@@ -1617,3 +1627,104 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(isPlatform("win32"))("async worktree metadata", () => {
|
||||
it("serializes metadata mutations per worktree while another worktree progresses", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "worktree-metadata-order-"));
|
||||
const first = join(root, "first");
|
||||
const second = join(root, "second");
|
||||
mkdirSync(join(first, ".git"), { recursive: true });
|
||||
mkdirSync(join(second, ".git"), { recursive: true });
|
||||
try {
|
||||
await writePaseoWorktreeMetadata(first, { baseRefName: "main" });
|
||||
await writePaseoWorktreeMetadata(second, { baseRefName: "release" });
|
||||
const order: string[] = [];
|
||||
const runtime = writePaseoWorktreeRuntimeMetadata(first, { worktreePort: 4101 }).then(() =>
|
||||
order.push("first-runtime"),
|
||||
);
|
||||
const unrelated = writePaseoWorktreeRuntimeMetadata(second, { worktreePort: 4102 }).then(() =>
|
||||
order.push("second-runtime"),
|
||||
);
|
||||
const naming = writePaseoWorktreeFirstAgentBranchAutoNameMetadata(first, {
|
||||
placeholderBranchName: "topic",
|
||||
}).then(() => order.push("first-naming"));
|
||||
// A read issued while writes are pending observes their completed snapshot.
|
||||
const snapshot = readPaseoWorktreeMetadata(first);
|
||||
await Promise.all([runtime, unrelated, naming]);
|
||||
expect(order.indexOf("first-runtime")).toBeLessThan(order.indexOf("first-naming"));
|
||||
expect(await snapshot).toMatchObject({
|
||||
baseRefName: "main",
|
||||
runtime: { worktreePort: 4101 },
|
||||
firstAgentBranchAutoName: { status: "pending", placeholderBranchName: "topic" },
|
||||
});
|
||||
expect(await readPaseoWorktreeMetadata(second)).toMatchObject({
|
||||
baseRefName: "release",
|
||||
runtime: { worktreePort: 4102 },
|
||||
});
|
||||
const alias = join(root, "first-alias");
|
||||
symlinkSync(first, alias, "dir");
|
||||
await Promise.all([
|
||||
writePaseoWorktreeRuntimeMetadata(first, { worktreePort: 4103 }),
|
||||
writePaseoWorktreeFirstAgentBranchAutoNameMetadata(alias, {
|
||||
placeholderBranchName: "renamed",
|
||||
}),
|
||||
]);
|
||||
expect(await readPaseoWorktreeMetadata(first)).toMatchObject({
|
||||
runtime: { worktreePort: 4103 },
|
||||
firstAgentBranchAutoName: { placeholderBranchName: "renamed" },
|
||||
});
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("a queued metadata mutation cannot recreate an archived Git directory", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "worktree-metadata-archive-"));
|
||||
mkdirSync(join(root, ".git"));
|
||||
try {
|
||||
await writePaseoWorktreeMetadata(root, { baseRefName: "main" });
|
||||
const pending = writePaseoWorktreeRuntimeMetadata(root, { worktreePort: 4101 });
|
||||
// The mutation is queued in a microtask, so archive wins this ordering.
|
||||
rmSync(join(root, ".git"), { recursive: true });
|
||||
await expect(pending).rejects.toThrow("Not a git repository");
|
||||
expect(existsSync(join(root, ".git"))).toBe(false);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("recognizes a removed worktree under a symlinked ownership root", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "worktree-ownership-alias-"));
|
||||
const actual = join(root, "actual");
|
||||
const alias = join(root, "worktrees");
|
||||
mkdirSync(actual);
|
||||
symlinkSync(actual, alias, "dir");
|
||||
try {
|
||||
const cwd = join(alias, "project", "removed");
|
||||
expect(
|
||||
await isPaseoOwnedWorktreeCwd(cwd, { worktreesRoot: alias, knownGitCommonDir: null }),
|
||||
).toMatchObject({ allowed: true, worktreePath: cwd });
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects oversized metadata and named pipes without reading them whole", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "worktree-metadata-admission-"));
|
||||
mkdirSync(join(root, ".git", "paseo"), { recursive: true });
|
||||
try {
|
||||
const path = await getPaseoWorktreeMetadataPath(root);
|
||||
writeFileSync(path, JSON.stringify({ version: 1, baseRefName: "x".repeat(64 * 1024) }));
|
||||
await expect(readPaseoWorktreeMetadata(root)).rejects.toThrow(
|
||||
"Invalid Paseo worktree metadata file",
|
||||
);
|
||||
rmSync(path);
|
||||
execFileSync("mkfifo", [path]);
|
||||
await expect(readPaseoWorktreeMetadata(root)).rejects.toThrow(
|
||||
"Invalid Paseo worktree metadata file",
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
deletePaseoWorktree,
|
||||
isPaseoOwnedWorktreeCwd,
|
||||
mapWorkspaceCwdToWorktree,
|
||||
runWorktreeTeardownCommands,
|
||||
slugify,
|
||||
type CreateWorktreeOptions,
|
||||
type WorktreeConfig,
|
||||
@@ -171,7 +172,15 @@ describe("paseo worktree manager", () => {
|
||||
expect(existsSync(created.worktreePath)).toBe(true);
|
||||
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(created.worktreePath, { paseoHome });
|
||||
expect(ownership.allowed).toBe(true);
|
||||
expect(ownership).toMatchObject({ allowed: true, worktreePath: created.worktreePath });
|
||||
// Windows may report the same root through native 8.3 and JS long spellings.
|
||||
const nativeWorktreePath = realpathSync.native(created.worktreePath);
|
||||
await expect(
|
||||
runWorktreeTeardownCommands({
|
||||
worktreePath: created.worktreePath,
|
||||
teardownCwd: nativeWorktreePath,
|
||||
}),
|
||||
).resolves.toEqual([]);
|
||||
await expect(
|
||||
isPaseoOwnedWorktreeCwd(join(created.worktreePath, "packages", "app"), { paseoHome }),
|
||||
).resolves.toMatchObject({
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { constants as fsConstants, existsSync, mkdirSync, realpathSync, statSync } from "fs";
|
||||
import { copyFile, rm, stat } from "fs/promises";
|
||||
import { constants as fsConstants, realpath } from "fs";
|
||||
import { copyFile, mkdir, rm, stat } from "fs/promises";
|
||||
import { join, basename, dirname, isAbsolute, resolve, sep } from "path";
|
||||
import net from "node:net";
|
||||
import { createHash } from "node:crypto";
|
||||
import stripAnsi from "strip-ansi";
|
||||
import pLimit from "p-limit";
|
||||
import {
|
||||
buildStringCommandShellInvocation,
|
||||
createStringCommandShellEnv,
|
||||
@@ -37,7 +38,12 @@ import { spawnProcess } from "./spawn.js";
|
||||
import { resolvePaseoHome } from "../server/paseo-home.js";
|
||||
import { createExternalProcessEnv } from "../server/paseo-env.js";
|
||||
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
|
||||
import { expandTilde, getRealpathAwareRelativePath, isPathInsideRoot } from "./path.js";
|
||||
import {
|
||||
expandTilde,
|
||||
getRealpathAwareRelativePath,
|
||||
getRelativePathInsideRoot,
|
||||
isPathInsideRoot,
|
||||
} from "./path.js";
|
||||
import { terminateWithTreeKill } from "./tree-kill.js";
|
||||
|
||||
export { slugify, validateBranchSlug } from "@getpaseo/protocol/branch-slug";
|
||||
@@ -595,7 +601,7 @@ async function assertPortAvailable(port: number): Promise<void> {
|
||||
async function inferRepoRootPathFromWorktreePath(worktreePath: string): Promise<string> {
|
||||
try {
|
||||
const commonDir = await getGitCommonDir(worktreePath);
|
||||
const normalizedCommonDir = normalizePathForOwnership(commonDir);
|
||||
const normalizedCommonDir = await normalizePathForOwnership(commonDir);
|
||||
// Normal repo/worktree: common dir is <repoRoot>/.git
|
||||
if (basename(normalizedCommonDir) === ".git") {
|
||||
return dirname(normalizedCommonDir);
|
||||
@@ -611,12 +617,12 @@ async function inferRepoRootPathFromWorktreePath(worktreePath: string): Promise<
|
||||
});
|
||||
const topLevel = parseGitRevParsePath(stdout);
|
||||
if (topLevel) {
|
||||
return normalizePathForOwnership(topLevel);
|
||||
return await normalizePathForOwnership(topLevel);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return normalizePathForOwnership(worktreePath);
|
||||
return await normalizePathForOwnership(worktreePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -699,12 +705,12 @@ export async function resolveWorktreeRuntimeEnv(options: {
|
||||
const branchName =
|
||||
options.branchName ?? (await resolveBranchNameForWorktreePath(options.worktreePath));
|
||||
|
||||
let worktreePort = readPaseoWorktreeRuntimePort(options.worktreePath);
|
||||
let worktreePort = await readPaseoWorktreeRuntimePort(options.worktreePath);
|
||||
if (worktreePort === null) {
|
||||
worktreePort = await getAvailablePort();
|
||||
const metadata = readPaseoWorktreeMetadata(options.worktreePath);
|
||||
const metadata = await readPaseoWorktreeMetadata(options.worktreePath);
|
||||
if (metadata) {
|
||||
writePaseoWorktreeRuntimeMetadata(options.worktreePath, { worktreePort });
|
||||
await writePaseoWorktreeRuntimeMetadata(options.worktreePath, { worktreePort });
|
||||
}
|
||||
} else {
|
||||
await assertPortAvailable(worktreePort);
|
||||
@@ -730,7 +736,7 @@ export async function runWorktreeTeardownCommands(options: {
|
||||
repoRootPath?: string;
|
||||
}): Promise<WorktreeTeardownCommandResult[]> {
|
||||
const teardownCwd = options.teardownCwd ?? options.worktreePath;
|
||||
if (getRealpathAwareRelativePath(options.worktreePath, teardownCwd) === null) {
|
||||
if ((await getOwnershipRelativePath(options.worktreePath, teardownCwd)) === null) {
|
||||
throw new Error(`Worktree teardown cwd is outside the worktree: ${teardownCwd}`);
|
||||
}
|
||||
const teardownCommands = await getWorktreeTeardownCommands(teardownCwd);
|
||||
@@ -742,7 +748,7 @@ export async function runWorktreeTeardownCommands(options: {
|
||||
options.repoRootPath ?? (await inferRepoRootPathFromWorktreePath(options.worktreePath));
|
||||
const branchName =
|
||||
options.branchName ?? (await resolveBranchNameForWorktreePath(options.worktreePath));
|
||||
const worktreePort = readPaseoWorktreeRuntimePort(options.worktreePath);
|
||||
const worktreePort = await readPaseoWorktreeRuntimePort(options.worktreePath);
|
||||
|
||||
const teardownEnv: NodeJS.ProcessEnv = createStringCommandShellEnv(
|
||||
createExternalProcessEnv(process.env, {
|
||||
@@ -819,12 +825,12 @@ function deriveShortAlphanumericHash(value: string): string {
|
||||
export async function deriveWorktreeProjectHash(cwd: string): Promise<string> {
|
||||
try {
|
||||
const commonDir = await getGitCommonDir(cwd);
|
||||
const normalizedCommonDir = normalizePathForOwnership(commonDir);
|
||||
const normalizedCommonDir = await normalizePathForOwnership(commonDir);
|
||||
const repoRoot =
|
||||
basename(normalizedCommonDir) === ".git" ? dirname(normalizedCommonDir) : normalizedCommonDir;
|
||||
return deriveShortAlphanumericHash(repoRoot);
|
||||
} catch {
|
||||
return deriveShortAlphanumericHash(normalizePathForOwnership(cwd));
|
||||
return deriveShortAlphanumericHash(await normalizePathForOwnership(cwd));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -892,16 +898,46 @@ export function mapWorkspaceRelativeCwdToWorktree(input: {
|
||||
return mappedCwd;
|
||||
}
|
||||
|
||||
function normalizePathForOwnership(input: string): string {
|
||||
// Keep the former realpathSync spelling on Windows: promises.realpath uses the
|
||||
// native resolver and can return an 8.3 alias instead of the long path.
|
||||
const resolveOwnershipPath = promisify(realpath);
|
||||
const resolveNativeOwnershipPath = promisify(realpath.native);
|
||||
const ownershipIdentityIo = pLimit(2);
|
||||
|
||||
async function normalizePathForOwnership(input: string): Promise<string> {
|
||||
try {
|
||||
return realpathSync(input);
|
||||
return await ownershipIdentityIo(() => resolveOwnershipPath(input));
|
||||
} catch {
|
||||
return resolve(input);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRepoRootFromGitCommonDir(commonDir: string): string {
|
||||
const normalizedCommonDir = normalizePathForOwnership(commonDir);
|
||||
async function ownershipPathVariants(input: string): Promise<string[]> {
|
||||
const variants = new Set([input, await normalizePathForOwnership(input)]);
|
||||
try {
|
||||
variants.add(await ownershipIdentityIo(() => resolveNativeOwnershipPath(input)));
|
||||
} catch {
|
||||
// Missing descendants still match a lexical or established root spelling.
|
||||
}
|
||||
return [...variants];
|
||||
}
|
||||
|
||||
// Keep lexical variants for missing paths: archive must still recognize a removed
|
||||
// worktree below a symlinked worktrees root, whose full realpath no longer exists.
|
||||
async function getOwnershipRelativePath(root: string, candidate: string): Promise<string | null> {
|
||||
const roots = await ownershipPathVariants(root);
|
||||
const candidates = await ownershipPathVariants(candidate);
|
||||
for (const rootPath of roots) {
|
||||
for (const candidatePath of candidates) {
|
||||
const relative = getRelativePathInsideRoot(rootPath, candidatePath);
|
||||
if (relative !== null) return relative;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveRepoRootFromGitCommonDir(commonDir: string): Promise<string> {
|
||||
const normalizedCommonDir = await normalizePathForOwnership(commonDir);
|
||||
return basename(normalizedCommonDir) === ".git"
|
||||
? dirname(normalizedCommonDir)
|
||||
: normalizedCommonDir;
|
||||
@@ -911,25 +947,25 @@ export async function isPaseoOwnedWorktreeCwd(
|
||||
cwd: string,
|
||||
options?: PaseoWorktreeOwnershipOptions,
|
||||
): Promise<PaseoWorktreeOwnership> {
|
||||
const resolvedCwd = normalizePathForOwnership(cwd);
|
||||
const resolvedCwd = await normalizePathForOwnership(cwd);
|
||||
|
||||
// repoRoot is best-effort: git may be unreachable from the worktree (e.g. a
|
||||
// previous archive attempt removed the admin dir before the working tree
|
||||
// could be fully cleaned up). We still want to allow archiving in that case.
|
||||
let repoRoot: string | undefined;
|
||||
if (options?.knownGitCommonDir) {
|
||||
repoRoot = resolveRepoRootFromGitCommonDir(options.knownGitCommonDir);
|
||||
repoRoot = await resolveRepoRootFromGitCommonDir(options.knownGitCommonDir);
|
||||
} else if (options?.knownGitCommonDir === undefined) {
|
||||
try {
|
||||
const gitCommonDir = await getGitCommonDir(cwd);
|
||||
repoRoot = resolveRepoRootFromGitCommonDir(gitCommonDir);
|
||||
repoRoot = await resolveRepoRootFromGitCommonDir(gitCommonDir);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const worktreesBaseRoot = resolvePaseoWorktreesBaseRoot(options);
|
||||
const relativePath = getRealpathAwareRelativePath(worktreesBaseRoot, resolvedCwd);
|
||||
const relativePath = await getOwnershipRelativePath(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
|
||||
@@ -1000,9 +1036,9 @@ function parseWorktreeList(output: string): ParsedPaseoWorktreeInfo[] {
|
||||
return entries;
|
||||
}
|
||||
|
||||
function resolveWorktreeCreatedAtIso(worktreePath: string): string {
|
||||
async function resolveWorktreeCreatedAtIso(worktreePath: string): Promise<string> {
|
||||
try {
|
||||
const stats = statSync(worktreePath);
|
||||
const stats = await stat(worktreePath);
|
||||
const birthtimeMs = stats.birthtimeMs;
|
||||
const createdAtMs =
|
||||
Number.isFinite(birthtimeMs) && birthtimeMs > 0 ? birthtimeMs : stats.ctimeMs;
|
||||
@@ -1027,12 +1063,15 @@ export async function listPaseoWorktrees({
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
|
||||
return parseWorktreeList(stdout)
|
||||
.map((entry) => Object.assign({}, entry, { path: normalizePathForOwnership(entry.path) }))
|
||||
.filter((entry) => getRealpathAwareRelativePath(projectWorktreesRoot, entry.path) !== null)
|
||||
.map((entry) =>
|
||||
Object.assign({}, entry, { createdAt: resolveWorktreeCreatedAtIso(entry.path) }),
|
||||
);
|
||||
const roots = await ownershipPathVariants(projectWorktreesRoot);
|
||||
const worktrees: PaseoWorktreeInfo[] = [];
|
||||
// Inspect foreign paths sequentially; a large worktree list must not consume the FS pool.
|
||||
for (const entry of parseWorktreeList(stdout)) {
|
||||
const path = await normalizePathForOwnership(entry.path);
|
||||
if (!roots.some((root) => getRelativePathInsideRoot(root, path) !== null)) continue;
|
||||
worktrees.push({ ...entry, path, createdAt: await resolveWorktreeCreatedAtIso(path) });
|
||||
}
|
||||
return worktrees;
|
||||
}
|
||||
|
||||
export interface DeletePaseoWorktreeOptions {
|
||||
@@ -1071,7 +1110,7 @@ export async function deletePaseoWorktree({
|
||||
}
|
||||
|
||||
const requestedPath = worktreePath ?? join(resolvedWorktreesRoot, worktreeSlug!);
|
||||
const resolvedRequested = normalizePathForOwnership(requestedPath);
|
||||
const resolvedRequested = await normalizePathForOwnership(requestedPath);
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(requestedPath, {
|
||||
paseoHome,
|
||||
worktreesRoot: worktreesBaseRoot,
|
||||
@@ -1079,7 +1118,7 @@ export async function deletePaseoWorktree({
|
||||
const resolvedWorktree =
|
||||
ownership.allowed && ownership.worktreePath ? ownership.worktreePath : resolvedRequested;
|
||||
|
||||
const relativeWorktreePath = getRealpathAwareRelativePath(
|
||||
const relativeWorktreePath = await getOwnershipRelativePath(
|
||||
resolvedWorktreesRoot,
|
||||
resolvedWorktree,
|
||||
);
|
||||
@@ -1195,12 +1234,12 @@ export const createWorktree = async ({
|
||||
}: CreateWorktreeOptions): Promise<CreatedWorktree> => {
|
||||
const sourcePlan = await resolveWorktreeSourcePlan({ cwd, source, desiredSlug: worktreeSlug });
|
||||
let worktreePath = join(await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot), worktreeSlug);
|
||||
mkdirSync(dirname(worktreePath), { recursive: true });
|
||||
await mkdir(dirname(worktreePath), { recursive: true });
|
||||
|
||||
// Also handle worktree path collision
|
||||
let finalWorktreePath = worktreePath;
|
||||
let pathSuffix = 1;
|
||||
while (existsSync(finalWorktreePath)) {
|
||||
while (await pathExists(finalWorktreePath)) {
|
||||
finalWorktreePath = `${worktreePath}-${pathSuffix}`;
|
||||
pathSuffix++;
|
||||
}
|
||||
@@ -1210,7 +1249,7 @@ export const createWorktree = async ({
|
||||
cwd,
|
||||
timeout: 120_000,
|
||||
});
|
||||
worktreePath = normalizePathForOwnership(finalWorktreePath);
|
||||
worktreePath = await normalizePathForOwnership(finalWorktreePath);
|
||||
|
||||
if (sourcePlan.pushRemote) {
|
||||
await configureWorktreePushRemote({
|
||||
@@ -1227,7 +1266,7 @@ export const createWorktree = async ({
|
||||
});
|
||||
}
|
||||
|
||||
writePaseoWorktreeMetadata(worktreePath, {
|
||||
await writePaseoWorktreeMetadata(worktreePath, {
|
||||
baseRefName: sourcePlan.metadataBaseRefName,
|
||||
...(sourcePlan.metadataBaseRef ? { baseRef: sourcePlan.metadataBaseRef } : {}),
|
||||
...(sourcePlan.changeRequestLookupTarget
|
||||
|
||||
Reference in New Issue
Block a user