diff --git a/packages/app/src/components/grouped-agent-list.tsx b/packages/app/src/components/grouped-agent-list.tsx index 94c15eb4c..510b5b101 100644 --- a/packages/app/src/components/grouped-agent-list.tsx +++ b/packages/app/src/components/grouped-agent-list.tsx @@ -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({ - {section.workingDir && ( + {createAgentWorkingDir && ( { 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", diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 6ddd9a2ba..8db383f24 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -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 { } } +async function getMainRepoRoot(cwd: string): Promise { + 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,