fix: use mainRepoRoot for agent creation in Paseo-owned worktrees

This commit is contained in:
Mohamed Boudra
2026-01-31 12:04:32 +07:00
parent ea33d1980f
commit c57c86286a
7 changed files with 74 additions and 7 deletions

View File

@@ -121,14 +121,17 @@ function SectionHeader({
}
}
const createAgentWorkingDir =
(checkout?.isPaseoOwnedWorktree ? checkout.mainRepoRoot : null) ?? section.workingDir;
const handleCreatePress = useCallback(
(e: { stopPropagation: () => void }) => {
e.stopPropagation();
if (section.workingDir) {
onCreateAgent(section.workingDir);
if (createAgentWorkingDir) {
onCreateAgent(createAgentWorkingDir);
}
},
[onCreateAgent, section.workingDir]
[onCreateAgent, createAgentWorkingDir]
);
return (
@@ -171,7 +174,7 @@ function SectionHeader({
</Text>
</View>
<View style={styles.sectionHeaderRight}>
{section.workingDir && (
{createAgentWorkingDir && (
<Pressable
style={styles.createAgentButton}
onPress={handleCreatePress}

View File

@@ -1218,7 +1218,8 @@ function assertHydratedReplica(
}
function sanitizeClaudeProjectName(cwd: string): string {
return cwd.replace(/[\\/]/g, "-").replace(/_/g, "-");
// Match Claude CLI's path sanitization: replace slashes, dots, and underscores with dashes
return cwd.replace(/[\\/\.]/g, "-").replace(/_/g, "-");
}
function resolveClaudeHistoryPath(cwd: string, sessionId: string): string {

View File

@@ -1260,7 +1260,8 @@ class ClaudeAgentSession implements AgentSession {
private resolveHistoryPath(sessionId: string): string | null {
const cwd = this.config.cwd;
if (!cwd) return null;
const sanitized = cwd.replace(/[\\/]/g, "-").replace(/_/g, "-");
// Match Claude CLI's path sanitization: replace slashes, dots, and underscores with dashes
const sanitized = cwd.replace(/[\\/\.]/g, "-").replace(/_/g, "-");
const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
const dir = path.join(configDir, "projects", sanitized);
return path.join(dir, `${sessionId}.jsonl`);

View File

@@ -2503,6 +2503,7 @@ export class Session {
cwd,
isGit: true,
repoRoot: status.repoRoot ?? null,
mainRepoRoot: status.mainRepoRoot,
currentBranch: status.currentBranch ?? null,
isDirty: status.isDirty ?? null,
baseRef: status.baseRef,

View File

@@ -1124,6 +1124,7 @@ const CheckoutStatusGitPaseoSchema = CheckoutStatusCommonSchema.extend({
isGit: z.literal(true),
isPaseoOwnedWorktree: z.literal(true),
repoRoot: z.string(),
mainRepoRoot: z.string(),
currentBranch: z.string().nullable(),
isDirty: z.boolean(),
baseRef: z.string(),

View File

@@ -117,6 +117,8 @@ describe("checkout git utilities", () => {
expect(status.isGit).toBe(true);
expect(status.repoRoot).toBe(result.worktreePath);
expect(status.isDirty).toBe(true);
expect(status.isPaseoOwnedWorktree).toBe(true);
expect(status.mainRepoRoot).toBe(repoDir);
const diff = await getCheckoutDiff(result.worktreePath, { mode: "uncommitted" }, { paseoHome });
expect(diff.diff).toContain("-hello");
@@ -134,6 +136,29 @@ describe("checkout git utilities", () => {
expect(message).toBe("worktree update");
});
it("returns mainRepoRoot pointing to first non-bare worktree for bare repos", async () => {
const bareRepoDir = join(tempDir, "bare-repo");
execSync(`git clone --bare ${repoDir} ${bareRepoDir}`);
const mainCheckoutDir = join(tempDir, "main-checkout");
execSync(`git -C ${bareRepoDir} worktree add ${mainCheckoutDir} main`);
execSync("git config user.email 'test@test.com'", { cwd: mainCheckoutDir });
execSync("git config user.name 'Test'", { cwd: mainCheckoutDir });
const worktree = await createWorktree({
branchName: "feature",
cwd: mainCheckoutDir,
baseBranch: "main",
worktreeSlug: "feature-worktree",
paseoHome,
});
const status = await getCheckoutStatus(worktree.worktreePath, { paseoHome });
expect(status.isGit).toBe(true);
expect(status.isPaseoOwnedWorktree).toBe(true);
expect(status.mainRepoRoot).toBe(mainCheckoutDir);
});
it("merges the current branch into base from a worktree checkout", async () => {
const worktree = await createWorktree({
branchName: "main",

View File

@@ -1,6 +1,7 @@
import { exec, execFile } from "child_process";
import { promisify } from "util";
import { resolve } from "path";
import { resolve, dirname, basename } from "path";
import { realpathSync } from "fs";
import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js";
import { isPaseoOwnedWorktreeCwd } from "./worktree.js";
@@ -79,6 +80,7 @@ export type CheckoutStatusGitNonPaseo = {
export type CheckoutStatusGitPaseo = {
isGit: true;
repoRoot: string;
mainRepoRoot: string;
currentBranch: string | null;
isDirty: boolean;
baseRef: string;
@@ -156,9 +158,37 @@ async function getWorktreeRoot(cwd: string): Promise<string | null> {
}
}
async function getMainRepoRoot(cwd: string): Promise<string> {
const { stdout: commonDirOut } = await execAsync(
"git rev-parse --path-format=absolute --git-common-dir",
{ cwd, env: READ_ONLY_GIT_ENV }
);
const commonDir = commonDirOut.trim();
const normalized = realpathSync(commonDir);
if (basename(normalized) === ".git") {
return dirname(normalized);
}
const { stdout: worktreeOut } = await execAsync("git worktree list --porcelain", {
cwd,
env: READ_ONLY_GIT_ENV,
});
const worktrees = parseWorktreeList(worktreeOut);
const nonBareNonPaseo = worktrees.filter(
(wt) => !wt.isBare && !wt.path.includes("/.paseo/worktrees/")
);
const childrenOfBareRepo = nonBareNonPaseo.filter((wt) =>
wt.path.startsWith(normalized + "/")
);
const mainChild = childrenOfBareRepo.find((wt) => basename(wt.path) === "main");
return mainChild?.path ?? childrenOfBareRepo[0]?.path ?? nonBareNonPaseo[0]?.path ?? normalized;
}
type GitWorktreeEntry = {
path: string;
branchRef?: string;
isBare?: boolean;
};
function parseWorktreeList(output: string): GitWorktreeEntry[] {
@@ -179,6 +209,9 @@ function parseWorktreeList(output: string): GitWorktreeEntry[] {
if (current && trimmed.startsWith("branch ")) {
current.branchRef = trimmed.slice("branch ".length).trim();
}
if (current && trimmed === "bare") {
current.isBare = true;
}
}
if (current) {
entries.push(current);
@@ -460,9 +493,11 @@ export async function getCheckoutStatus(
hasRemote && currentBranch ? await getAheadOfOrigin(cwd, currentBranch) : null;
if (configured.isPaseoOwnedWorktree) {
const mainRepoRoot = await getMainRepoRoot(cwd);
return {
isGit: true,
repoRoot: worktreeRoot,
mainRepoRoot,
currentBranch,
isDirty,
baseRef: configured.baseRef,