From 0354c409f13da5fe8cb45c980f151ce5f716ca59 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 17 Feb 2026 23:03:36 +0700 Subject: [PATCH] fix(worktree): hash cwd for collision-safe worktree roots --- .../daemon-e2e/git-operations.e2e.test.ts | 7 +- packages/server/src/utils/worktree.test.ts | 63 ++++++++++-- packages/server/src/utils/worktree.ts | 95 ++++--------------- .../website/src/routes/docs/worktrees.tsx | 12 +-- 4 files changed, 88 insertions(+), 89 deletions(-) diff --git a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts index 6310af8a3..268d7290f 100644 --- a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts @@ -7,7 +7,7 @@ import { type DaemonTestContext, } from "../test-utils/index.js"; import { createMessageCollector, type MessageCollector } from "../test-utils/message-collector.js"; -import { slugify } from "../../utils/worktree.js"; +import { deriveWorktreeProjectHash } from "../../utils/worktree.js"; import type { AgentTimelineItem } from "../agent/agent-sdk-types.js"; import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js"; @@ -685,9 +685,10 @@ describe("daemon E2E", () => { describe("createAgent with worktree", () => { test( - "creates agent in ~/.paseo/worktrees/{project} when worktree is requested", + "creates agent in ~/.paseo/worktrees/{hash} when worktree is requested", async () => { const cwd = tmpCwd(); + const projectHash = await deriveWorktreeProjectHash(cwd); const { execSync } = await import("child_process"); execSync("git init -b main", { cwd, stdio: "pipe" }); @@ -724,7 +725,7 @@ describe("daemon E2E", () => { path.join( ctx.daemon.paseoHome, "worktrees", - slugify(path.basename(cwd)), + projectHash, "worktree-test" ) ) diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index 9bf1bed2a..d37b48ff8 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { createWorktree, + deriveWorktreeProjectHash, deletePaseoWorktree, getWorktreeTerminalSpecs, isPaseoOwnedWorktreeCwd, @@ -13,7 +14,7 @@ import { import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js"; import { execSync } from "child_process"; import { mkdtempSync, rmSync, existsSync, realpathSync, writeFileSync, readFileSync } from "fs"; -import { join } from "path"; +import { dirname, join } from "path"; import { tmpdir } from "os"; import net from "node:net"; @@ -43,6 +44,7 @@ describe("createWorktree", () => { }); it("creates a worktree for the current branch (main)", async () => { + const projectHash = await deriveWorktreeProjectHash(repoDir); const result = await createWorktree({ branchName: "main", cwd: repoDir, @@ -52,7 +54,7 @@ describe("createWorktree", () => { }); expect(result.worktreePath).toBe( - join(paseoHome, "worktrees", "test-repo", "hello-world") + join(paseoHome, "worktrees", projectHash, "hello-world") ); expect(existsSync(result.worktreePath)).toBe(true); expect(existsSync(join(result.worktreePath, "file.txt"))).toBe(true); @@ -86,7 +88,14 @@ describe("createWorktree", () => { paseoHome: varPaseoHome, }); - const privateWorktreePath = join(privateTempDir, "paseo-home", "worktrees", "test-repo", "realpath-test"); + const projectHash = await deriveWorktreeProjectHash(varRepoDir); + const privateWorktreePath = join( + privateTempDir, + "paseo-home", + "worktrees", + projectHash, + "realpath-test" + ); expect(existsSync(privateWorktreePath)).toBe(true); const ownership = await isPaseoOwnedWorktreeCwd(privateWorktreePath, { paseoHome: varPaseoHome }); @@ -96,6 +105,7 @@ describe("createWorktree", () => { }); it("creates a worktree with a new branch", async () => { + const projectHash = await deriveWorktreeProjectHash(repoDir); const result = await createWorktree({ branchName: "feature-branch", cwd: repoDir, @@ -105,7 +115,7 @@ describe("createWorktree", () => { }); expect(result.worktreePath).toBe( - join(paseoHome, "worktrees", "test-repo", "my-feature") + join(paseoHome, "worktrees", projectHash, "my-feature") ); expect(existsSync(result.worktreePath)).toBe(true); @@ -131,6 +141,7 @@ describe("createWorktree", () => { }); it("handles branch name collision by adding suffix", async () => { + const projectHash = await deriveWorktreeProjectHash(repoDir); // Create a branch named "hello" first execSync("git branch hello", { cwd: repoDir }); @@ -144,7 +155,7 @@ describe("createWorktree", () => { // Should create branch "hello-1" since "hello" exists expect(result.worktreePath).toBe( - join(paseoHome, "worktrees", "test-repo", "hello") + join(paseoHome, "worktrees", projectHash, "hello") ); expect(existsSync(result.worktreePath)).toBe(true); @@ -421,7 +432,47 @@ describe("paseo worktree manager", () => { rmSync(tempDir, { recursive: true, force: true }); }); - it("lists and deletes paseo worktrees under ~/.paseo/worktrees/{project}", async () => { + it("isolates worktree roots for repositories that share the same directory name", async () => { + const repoA = join(tempDir, "team-a", "test-repo"); + const repoB = join(tempDir, "team-b", "test-repo"); + + for (const repo of [repoA, repoB]) { + execSync(`mkdir -p ${repo}`); + execSync("git init -b main", { cwd: repo }); + execSync("git config user.email 'test@test.com'", { cwd: repo }); + execSync("git config user.name 'Test'", { cwd: repo }); + execSync("echo 'hello' > file.txt", { cwd: repo }); + execSync("git add .", { cwd: repo }); + execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repo }); + } + + const fromRepoA = await createWorktree({ + branchName: "main", + cwd: repoA, + baseBranch: "main", + worktreeSlug: "alpha", + paseoHome, + }); + const fromRepoB = await createWorktree({ + branchName: "main", + cwd: repoB, + baseBranch: "main", + worktreeSlug: "alpha", + paseoHome, + }); + + expect(dirname(fromRepoA.worktreePath)).not.toBe(dirname(fromRepoB.worktreePath)); + expect(fromRepoA.worktreePath.endsWith("alpha-1")).toBe(false); + expect(fromRepoB.worktreePath.endsWith("alpha-1")).toBe(false); + + const repoAWorktrees = await listPaseoWorktrees({ cwd: repoA, paseoHome }); + const repoBWorktrees = await listPaseoWorktrees({ cwd: repoB, paseoHome }); + + expect(repoAWorktrees.map((entry) => entry.path)).toEqual([fromRepoA.worktreePath]); + expect(repoBWorktrees.map((entry) => entry.path)).toEqual([fromRepoB.worktreePath]); + }); + + it("lists and deletes paseo worktrees under ~/.paseo/worktrees/{hash}", async () => { const first = await createWorktree({ branchName: "main", cwd: repoDir, diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index a8acc14e6..a9e61f34e 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -3,6 +3,7 @@ import { promisify } from "util"; import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from "fs"; import { join, basename, dirname, resolve, sep } from "path"; import net from "node:net"; +import { createHash } from "node:crypto"; import { createNameId } from "mnemonic-id"; import { normalizeBaseRefName, @@ -639,91 +640,37 @@ function generateWorktreeSlug(): string { return createNameId(); } -function tryParseGitRemote(remoteUrl: string): { host?: string; path: string } | null { - const cleaned = remoteUrl.trim().replace(/\.git$/i, ""); - if (!cleaned) { - return null; - } +const WORKTREE_PROJECT_HASH_LENGTH = 8; - if (cleaned.includes("://")) { - try { - const url = new URL(cleaned); - return { host: url.hostname, path: url.pathname.replace(/^\/+/, "") }; - } catch { - // fall through - } +function deriveShortAlphanumericHash(value: string): string { + const digest = createHash("sha256").update(value).digest(); + let hashValue = 0n; + for (let index = 0; index < 8; index += 1) { + hashValue = (hashValue << 8n) | BigInt(digest[index] ?? 0); } - - // Support scp-like syntax: git@github.com:owner/repo - const scpMatch = cleaned.match(/^(?:.+@)?([^:]+):(.+)$/); - if (scpMatch?.[2]) { - return { host: scpMatch[1], path: scpMatch[2].replace(/^\/+/, "") }; - } - - return { path: cleaned.replace(/^\/+/, "") }; + return hashValue + .toString(36) + .padStart(13, "0") + .slice(0, WORKTREE_PROJECT_HASH_LENGTH); } -function inferProjectNameFromRemote(remoteUrl: string): string | null { - const parsed = tryParseGitRemote(remoteUrl); - if (!parsed?.path) { - return null; - } - const segments = parsed.path.split("/").filter((segment) => segment.length > 0); - if (segments.length === 0) { - return null; - } - return segments[segments.length - 1] ?? null; -} - -async function detectWorktreeProject(cwd: string): Promise { - // First try to get project name from remote URL (consistent across worktrees) +export async function deriveWorktreeProjectHash(cwd: string): Promise { try { - const { stdout } = await execAsync("git config --get remote.origin.url", { - cwd, - env: READ_ONLY_GIT_ENV, - }); - const remote = stdout.trim(); - if (remote) { - const inferred = inferProjectNameFromRemote(remote); - if (inferred) { - const projected = slugify(inferred); - if (projected) { - return projected; - } - } - } + const commonDir = await getGitCommonDir(cwd); + const normalizedCommonDir = normalizePathForOwnership(commonDir); + const repoRoot = basename(normalizedCommonDir) === ".git" + ? dirname(normalizedCommonDir) + : normalizedCommonDir; + return deriveShortAlphanumericHash(repoRoot); } catch { - // ignore - } - - // Fallback: derive from git common dir parent (works for both main checkout and worktrees) - // For normal repos: .git -> parent is repo root - // For bare repos: the bare repo dir itself - // For worktrees: common dir points to main repo's .git - try { - const { stdout } = await execAsync("git rev-parse --git-common-dir", { - cwd, - env: READ_ONLY_GIT_ENV, - }); - const commonDir = stdout.trim(); - const normalized = realpathSync(commonDir); - // If common dir ends with .git, use its parent. Otherwise use its parent too (bare repo case). - const repoRoot = basename(normalized) === ".git" ? dirname(normalized) : dirname(normalized); - return slugify(basename(repoRoot)); - } catch { - // Last resort: use cwd - try { - return slugify(basename(realpathSync(cwd))); - } catch { - return slugify(basename(cwd)); - } + return deriveShortAlphanumericHash(normalizePathForOwnership(cwd)); } } async function getPaseoWorktreesRoot(cwd: string, paseoHome?: string): Promise { const home = paseoHome ? resolve(paseoHome) : resolvePaseoHome(); - const project = await detectWorktreeProject(cwd); - return join(home, "worktrees", project); + const projectHash = await deriveWorktreeProjectHash(cwd); + return join(home, "worktrees", projectHash); } function normalizePathForOwnership(input: string): string { diff --git a/packages/website/src/routes/docs/worktrees.tsx b/packages/website/src/routes/docs/worktrees.tsx index 0d8bbf0f4..4e94cf85b 100644 --- a/packages/website/src/routes/docs/worktrees.tsx +++ b/packages/website/src/routes/docs/worktrees.tsx @@ -52,20 +52,20 @@ function Worktrees() {

Directory structure

Paseo creates worktrees under $PASEO_HOME/worktrees/, - organized by project: + organized by a short hash of the source checkout path:

{`~/.paseo/worktrees/
-├── my-project/
+├── 1vnnm9k3/
 │   ├── tidy-fox/            # random slug
 │   └── bold-owl/            # random slug
-└── another-repo/
+└── 4k8q2d1p/
     └── swift-hare/          # random slug`}

- The project name is derived from your git remote URL or repository directory name. - Directory names are random slugs — the branch name is separate and chosen when - you first launch an agent in the worktree. + The hash avoids collisions between repositories that share the same directory or + remote name. Worktree directory names are random slugs — the branch name is + separate and chosen when you first launch an agent in the worktree.