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:
@@ -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"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user