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