Merge pull request #49 from getpaseo/feat/issue-41-tdd-clean

fix: hash worktree root by cwd to prevent path clashes
This commit is contained in:
Mohamed Boudra
2026-02-18 09:58:35 +07:00
committed by GitHub
4 changed files with 88 additions and 89 deletions
@@ -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"
)
)
+57 -6
View File
@@ -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,
+21 -74
View File
@@ -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<string> {
// First try to get project name from remote URL (consistent across worktrees)
export async function deriveWorktreeProjectHash(cwd: string): Promise<string> {
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<string> {
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 {
@@ -52,20 +52,20 @@ function Worktrees() {
<h2 className="text-xl font-medium">Directory structure</h2>
<p className="text-white/60 leading-relaxed">
Paseo creates worktrees under <code className="font-mono">$PASEO_HOME/worktrees/</code>,
organized by project:
organized by a short hash of the source checkout path:
</p>
<Code>
<pre className="text-white/80">{`~/.paseo/worktrees/
├── my-project/
├── 1vnnm9k3/
│ ├── tidy-fox/ # random slug
│ └── bold-owl/ # random slug
└── another-repo/
└── 4k8q2d1p/
└── swift-hare/ # random slug`}</pre>
</Code>
<p className="text-white/60 leading-relaxed">
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.
</p>
</section>