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, type DaemonTestContext,
} from "../test-utils/index.js"; } from "../test-utils/index.js";
import { createMessageCollector, type MessageCollector } from "../test-utils/message-collector.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 { AgentTimelineItem } from "../agent/agent-sdk-types.js";
import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js"; import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js";
@@ -685,9 +685,10 @@ describe("daemon E2E", () => {
describe("createAgent with worktree", () => { describe("createAgent with worktree", () => {
test( test(
"creates agent in ~/.paseo/worktrees/{project} when worktree is requested", "creates agent in ~/.paseo/worktrees/{hash} when worktree is requested",
async () => { async () => {
const cwd = tmpCwd(); const cwd = tmpCwd();
const projectHash = await deriveWorktreeProjectHash(cwd);
const { execSync } = await import("child_process"); const { execSync } = await import("child_process");
execSync("git init -b main", { cwd, stdio: "pipe" }); execSync("git init -b main", { cwd, stdio: "pipe" });
@@ -724,7 +725,7 @@ describe("daemon E2E", () => {
path.join( path.join(
ctx.daemon.paseoHome, ctx.daemon.paseoHome,
"worktrees", "worktrees",
slugify(path.basename(cwd)), projectHash,
"worktree-test" "worktree-test"
) )
) )
+57 -6
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { import {
createWorktree, createWorktree,
deriveWorktreeProjectHash,
deletePaseoWorktree, deletePaseoWorktree,
getWorktreeTerminalSpecs, getWorktreeTerminalSpecs,
isPaseoOwnedWorktreeCwd, isPaseoOwnedWorktreeCwd,
@@ -13,7 +14,7 @@ import {
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js"; import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
import { execSync } from "child_process"; import { execSync } from "child_process";
import { mkdtempSync, rmSync, existsSync, realpathSync, writeFileSync, readFileSync } from "fs"; import { mkdtempSync, rmSync, existsSync, realpathSync, writeFileSync, readFileSync } from "fs";
import { join } from "path"; import { dirname, join } from "path";
import { tmpdir } from "os"; import { tmpdir } from "os";
import net from "node:net"; import net from "node:net";
@@ -43,6 +44,7 @@ describe("createWorktree", () => {
}); });
it("creates a worktree for the current branch (main)", async () => { it("creates a worktree for the current branch (main)", async () => {
const projectHash = await deriveWorktreeProjectHash(repoDir);
const result = await createWorktree({ const result = await createWorktree({
branchName: "main", branchName: "main",
cwd: repoDir, cwd: repoDir,
@@ -52,7 +54,7 @@ describe("createWorktree", () => {
}); });
expect(result.worktreePath).toBe( 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(result.worktreePath)).toBe(true);
expect(existsSync(join(result.worktreePath, "file.txt"))).toBe(true); expect(existsSync(join(result.worktreePath, "file.txt"))).toBe(true);
@@ -86,7 +88,14 @@ describe("createWorktree", () => {
paseoHome: varPaseoHome, 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); expect(existsSync(privateWorktreePath)).toBe(true);
const ownership = await isPaseoOwnedWorktreeCwd(privateWorktreePath, { paseoHome: varPaseoHome }); const ownership = await isPaseoOwnedWorktreeCwd(privateWorktreePath, { paseoHome: varPaseoHome });
@@ -96,6 +105,7 @@ describe("createWorktree", () => {
}); });
it("creates a worktree with a new branch", async () => { it("creates a worktree with a new branch", async () => {
const projectHash = await deriveWorktreeProjectHash(repoDir);
const result = await createWorktree({ const result = await createWorktree({
branchName: "feature-branch", branchName: "feature-branch",
cwd: repoDir, cwd: repoDir,
@@ -105,7 +115,7 @@ describe("createWorktree", () => {
}); });
expect(result.worktreePath).toBe( expect(result.worktreePath).toBe(
join(paseoHome, "worktrees", "test-repo", "my-feature") join(paseoHome, "worktrees", projectHash, "my-feature")
); );
expect(existsSync(result.worktreePath)).toBe(true); expect(existsSync(result.worktreePath)).toBe(true);
@@ -131,6 +141,7 @@ describe("createWorktree", () => {
}); });
it("handles branch name collision by adding suffix", async () => { it("handles branch name collision by adding suffix", async () => {
const projectHash = await deriveWorktreeProjectHash(repoDir);
// Create a branch named "hello" first // Create a branch named "hello" first
execSync("git branch hello", { cwd: repoDir }); execSync("git branch hello", { cwd: repoDir });
@@ -144,7 +155,7 @@ describe("createWorktree", () => {
// Should create branch "hello-1" since "hello" exists // Should create branch "hello-1" since "hello" exists
expect(result.worktreePath).toBe( expect(result.worktreePath).toBe(
join(paseoHome, "worktrees", "test-repo", "hello") join(paseoHome, "worktrees", projectHash, "hello")
); );
expect(existsSync(result.worktreePath)).toBe(true); expect(existsSync(result.worktreePath)).toBe(true);
@@ -421,7 +432,47 @@ describe("paseo worktree manager", () => {
rmSync(tempDir, { recursive: true, force: true }); 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({ const first = await createWorktree({
branchName: "main", branchName: "main",
cwd: repoDir, cwd: repoDir,
+21 -74
View File
@@ -3,6 +3,7 @@ import { promisify } from "util";
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from "fs"; import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from "fs";
import { join, basename, dirname, resolve, sep } from "path"; import { join, basename, dirname, resolve, sep } from "path";
import net from "node:net"; import net from "node:net";
import { createHash } from "node:crypto";
import { createNameId } from "mnemonic-id"; import { createNameId } from "mnemonic-id";
import { import {
normalizeBaseRefName, normalizeBaseRefName,
@@ -639,91 +640,37 @@ function generateWorktreeSlug(): string {
return createNameId(); return createNameId();
} }
function tryParseGitRemote(remoteUrl: string): { host?: string; path: string } | null { const WORKTREE_PROJECT_HASH_LENGTH = 8;
const cleaned = remoteUrl.trim().replace(/\.git$/i, "");
if (!cleaned) {
return null;
}
if (cleaned.includes("://")) { function deriveShortAlphanumericHash(value: string): string {
try { const digest = createHash("sha256").update(value).digest();
const url = new URL(cleaned); let hashValue = 0n;
return { host: url.hostname, path: url.pathname.replace(/^\/+/, "") }; for (let index = 0; index < 8; index += 1) {
} catch { hashValue = (hashValue << 8n) | BigInt(digest[index] ?? 0);
// fall through
}
} }
return hashValue
// Support scp-like syntax: git@github.com:owner/repo .toString(36)
const scpMatch = cleaned.match(/^(?:.+@)?([^:]+):(.+)$/); .padStart(13, "0")
if (scpMatch?.[2]) { .slice(0, WORKTREE_PROJECT_HASH_LENGTH);
return { host: scpMatch[1], path: scpMatch[2].replace(/^\/+/, "") };
}
return { path: cleaned.replace(/^\/+/, "") };
} }
function inferProjectNameFromRemote(remoteUrl: string): string | null { export async function deriveWorktreeProjectHash(cwd: string): Promise<string> {
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)
try { try {
const { stdout } = await execAsync("git config --get remote.origin.url", { const commonDir = await getGitCommonDir(cwd);
cwd, const normalizedCommonDir = normalizePathForOwnership(commonDir);
env: READ_ONLY_GIT_ENV, const repoRoot = basename(normalizedCommonDir) === ".git"
}); ? dirname(normalizedCommonDir)
const remote = stdout.trim(); : normalizedCommonDir;
if (remote) { return deriveShortAlphanumericHash(repoRoot);
const inferred = inferProjectNameFromRemote(remote);
if (inferred) {
const projected = slugify(inferred);
if (projected) {
return projected;
}
}
}
} catch { } catch {
// ignore return deriveShortAlphanumericHash(normalizePathForOwnership(cwd));
}
// 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));
}
} }
} }
async function getPaseoWorktreesRoot(cwd: string, paseoHome?: string): Promise<string> { async function getPaseoWorktreesRoot(cwd: string, paseoHome?: string): Promise<string> {
const home = paseoHome ? resolve(paseoHome) : resolvePaseoHome(); const home = paseoHome ? resolve(paseoHome) : resolvePaseoHome();
const project = await detectWorktreeProject(cwd); const projectHash = await deriveWorktreeProjectHash(cwd);
return join(home, "worktrees", project); return join(home, "worktrees", projectHash);
} }
function normalizePathForOwnership(input: string): string { function normalizePathForOwnership(input: string): string {
@@ -52,20 +52,20 @@ function Worktrees() {
<h2 className="text-xl font-medium">Directory structure</h2> <h2 className="text-xl font-medium">Directory structure</h2>
<p className="text-white/60 leading-relaxed"> <p className="text-white/60 leading-relaxed">
Paseo creates worktrees under <code className="font-mono">$PASEO_HOME/worktrees/</code>, 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> </p>
<Code> <Code>
<pre className="text-white/80">{`~/.paseo/worktrees/ <pre className="text-white/80">{`~/.paseo/worktrees/
├── my-project/ ├── 1vnnm9k3/
│ ├── tidy-fox/ # random slug │ ├── tidy-fox/ # random slug
│ └── bold-owl/ # random slug │ └── bold-owl/ # random slug
└── another-repo/ └── 4k8q2d1p/
└── swift-hare/ # random slug`}</pre> └── swift-hare/ # random slug`}</pre>
</Code> </Code>
<p className="text-white/60 leading-relaxed"> <p className="text-white/60 leading-relaxed">
The project name is derived from your git remote URL or repository directory name. The hash avoids collisions between repositories that share the same directory or
Directory names are random slugs — the branch name is separate and chosen when remote name. Worktree directory names are random slugs — the branch name is
you first launch an agent in the worktree. separate and chosen when you first launch an agent in the worktree.
</p> </p>
</section> </section>