Persist base branch for Paseo worktrees and gate merge action
This commit is contained in:
@@ -108,7 +108,7 @@ async function createAgentAndWait(page: Page, message: string) {
|
|||||||
async function requestCwd(page: Page) {
|
async function requestCwd(page: Page) {
|
||||||
await sendPrompt(page, 'Run `pwd` and respond with exactly: CWD: <path>');
|
await sendPrompt(page, 'Run `pwd` and respond with exactly: CWD: <path>');
|
||||||
const message = await waitForAssistantText(page, 'CWD:');
|
const message = await waitForAssistantText(page, 'CWD:');
|
||||||
const content = await message.innerText();
|
const content = (await message.textContent()) ?? '';
|
||||||
const match = content.match(/CWD:\s*(\S+)/);
|
const match = content.match(/CWD:\s*(\S+)/);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
throw new Error(`Expected agent to respond with "CWD: <path>", got: ${content}`);
|
throw new Error(`Expected agent to respond with "CWD: <path>", got: ${content}`);
|
||||||
@@ -303,6 +303,8 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
|
|||||||
await expect(getChangesScope(page).getByText('No base changes')).toBeVisible({
|
await expect(getChangesScope(page).getByText('No base changes')).toBeVisible({
|
||||||
timeout: 60000,
|
timeout: 60000,
|
||||||
});
|
});
|
||||||
|
await refreshChangesTab(page);
|
||||||
|
await expect(getChangesActionLabel(page, 'Merge to base')).toHaveCount(0, { timeout: 30000 });
|
||||||
|
|
||||||
await getChangesActionButton(page, 'Archive').click();
|
await getChangesActionButton(page, 'Archive').click();
|
||||||
// Archiving a worktree deletes agents and redirects to home
|
// Archiving a worktree deletes agents and redirects to home
|
||||||
@@ -331,7 +333,7 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
|
|||||||
await expect(getChangesScope(page).getByTestId('changes-not-git')).toBeVisible();
|
await expect(getChangesScope(page).getByTestId('changes-not-git')).toBeVisible();
|
||||||
await expect(getChangesActionButton(page, 'Commit')).toHaveAttribute('aria-disabled', 'true');
|
await expect(getChangesActionButton(page, 'Commit')).toHaveAttribute('aria-disabled', 'true');
|
||||||
await expect(getChangesActionButton(page, 'Create PR')).toHaveAttribute('aria-disabled', 'true');
|
await expect(getChangesActionButton(page, 'Create PR')).toHaveAttribute('aria-disabled', 'true');
|
||||||
await expect(getChangesActionButton(page, 'Merge to base')).toHaveAttribute('aria-disabled', 'true');
|
await expect(getChangesActionLabel(page, 'Merge to base')).toHaveCount(0);
|
||||||
} finally {
|
} finally {
|
||||||
await rm(nonGitDir, { recursive: true, force: true });
|
await rm(nonGitDir, { recursive: true, force: true });
|
||||||
await repo.cleanup();
|
await repo.cleanup();
|
||||||
|
|||||||
@@ -416,9 +416,12 @@ export default function HomeScreen() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const baseBranchError = useMemo(() => {
|
const baseBranchError = useMemo(() => {
|
||||||
if (!isCreateWorktree || isNonGitDirectory || !baseBranch) {
|
if (!isCreateWorktree || isNonGitDirectory) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (!baseBranch) {
|
||||||
|
return "Base branch is required";
|
||||||
|
}
|
||||||
const branches = repoInfo?.branches ?? [];
|
const branches = repoInfo?.branches ?? [];
|
||||||
if (branches.length === 0) {
|
if (branches.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -442,6 +445,19 @@ export default function HomeScreen() {
|
|||||||
[setWorkingDirFromUser]
|
[setWorkingDirFromUser]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isCreateWorktree || isNonGitDirectory) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (baseBranch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const current = repoInfo?.currentBranch?.trim();
|
||||||
|
if (current) {
|
||||||
|
setBaseBranch(current);
|
||||||
|
}
|
||||||
|
}, [isCreateWorktree, isNonGitDirectory, baseBranch, repoInfo?.currentBranch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isNonGitDirectory && worktreeMode !== "none") {
|
if (isNonGitDirectory && worktreeMode !== "none") {
|
||||||
setWorktreeMode("none");
|
setWorktreeMode("none");
|
||||||
@@ -514,7 +530,7 @@ export default function HomeScreen() {
|
|||||||
...(modeId ? { modeId } : {}),
|
...(modeId ? { modeId } : {}),
|
||||||
...(trimmedModel ? { model: trimmedModel } : {}),
|
...(trimmedModel ? { model: trimmedModel } : {}),
|
||||||
};
|
};
|
||||||
const effectiveBaseBranch = baseBranch.trim() || repoInfo?.currentBranch || undefined;
|
const effectiveBaseBranch = baseBranch.trim();
|
||||||
const effectiveWorktreeSlug =
|
const effectiveWorktreeSlug =
|
||||||
isCreateWorktree && !worktreeSlug ? createNameId() : worktreeSlug;
|
isCreateWorktree && !worktreeSlug ? createNameId() : worktreeSlug;
|
||||||
if (isCreateWorktree && !worktreeSlug && effectiveWorktreeSlug) {
|
if (isCreateWorktree && !worktreeSlug && effectiveWorktreeSlug) {
|
||||||
|
|||||||
@@ -356,12 +356,13 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
|||||||
const [actionStatus, setActionStatus] = useState<string | null>(null);
|
const [actionStatus, setActionStatus] = useState<string | null>(null);
|
||||||
const { status, isLoading: isStatusLoading, isFetching: isStatusFetching, isError: isStatusError, error: statusError, refresh: refreshStatus } =
|
const { status, isLoading: isStatusLoading, isFetching: isStatusFetching, isError: isStatusError, error: statusError, refresh: refreshStatus } =
|
||||||
useCheckoutStatusQuery({ serverId, agentId });
|
useCheckoutStatusQuery({ serverId, agentId });
|
||||||
const isGit = status?.isGit ?? false;
|
const gitStatus = status && status.isGit ? status : null;
|
||||||
const notGit = Boolean(status) && !status?.isGit && !status?.error;
|
const isGit = Boolean(gitStatus);
|
||||||
|
const notGit = status !== null && !status.isGit && !status.error;
|
||||||
const statusErrorMessage =
|
const statusErrorMessage =
|
||||||
status?.error?.message ??
|
status?.error?.message ??
|
||||||
(isStatusError && statusError instanceof Error ? statusError.message : null);
|
(isStatusError && statusError instanceof Error ? statusError.message : null);
|
||||||
const baseRef = status?.baseRef ?? undefined;
|
const baseRef = gitStatus?.baseRef ?? undefined;
|
||||||
const {
|
const {
|
||||||
files,
|
files,
|
||||||
payloadError: diffPayloadError,
|
payloadError: diffPayloadError,
|
||||||
@@ -593,17 +594,22 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
|||||||
diffPayloadError?.message ??
|
diffPayloadError?.message ??
|
||||||
(isDiffError && diffError instanceof Error ? diffError.message : null);
|
(isDiffError && diffError instanceof Error ? diffError.message : null);
|
||||||
const prErrorMessage = prPayloadError?.message ?? null;
|
const prErrorMessage = prPayloadError?.message ?? null;
|
||||||
const branchLabel = status?.currentBranch ?? (notGit ? "Not a git repository" : "Unknown");
|
const branchLabel =
|
||||||
|
gitStatus?.currentBranch ?? (notGit ? "Not a git repository" : "Unknown");
|
||||||
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
|
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
|
||||||
const hasUncommittedChanges = Boolean(status?.isDirty);
|
const hasUncommittedChanges = Boolean(gitStatus?.isDirty);
|
||||||
const showCommitAction =
|
const showCommitAction =
|
||||||
!isGit || hasUncommittedChanges || (diffMode === "uncommitted" && hasChanges);
|
!isGit || hasUncommittedChanges || (diffMode === "uncommitted" && hasChanges);
|
||||||
const showCreatePrAction = !isGit || !prStatus;
|
const showCreatePrAction = !isGit || !prStatus;
|
||||||
|
const showMergeAction =
|
||||||
|
diffMode === "base" && isGit && (gitStatus?.aheadBehind?.ahead ?? 0) > 0;
|
||||||
const commitDisabled = actionsDisabled || commitMutation.isPending;
|
const commitDisabled = actionsDisabled || commitMutation.isPending;
|
||||||
const prDisabled = actionsDisabled || prMutation.isPending;
|
const prDisabled = actionsDisabled || prMutation.isPending;
|
||||||
const mergeDisabled = actionsDisabled || mergeMutation.isPending;
|
const mergeDisabled = actionsDisabled || mergeMutation.isPending;
|
||||||
const archiveDisabled =
|
const archiveDisabled =
|
||||||
actionsDisabled || archiveMutation.isPending || !status?.isPaseoOwnedWorktree;
|
actionsDisabled ||
|
||||||
|
archiveMutation.isPending ||
|
||||||
|
!gitStatus?.isPaseoOwnedWorktree;
|
||||||
|
|
||||||
let bodyContent: ReactElement;
|
let bodyContent: ReactElement;
|
||||||
|
|
||||||
@@ -745,18 +751,20 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
|||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : null}
|
) : null}
|
||||||
<Pressable
|
{showMergeAction ? (
|
||||||
testID="changes-action-merge"
|
<Pressable
|
||||||
style={[styles.actionButton, mergeDisabled && styles.actionButtonDisabled]}
|
testID="changes-action-merge"
|
||||||
onPress={() => mergeMutation.mutate()}
|
style={[styles.actionButton, mergeDisabled && styles.actionButtonDisabled]}
|
||||||
disabled={mergeDisabled}
|
onPress={() => mergeMutation.mutate()}
|
||||||
>
|
disabled={mergeDisabled}
|
||||||
{mergeMutation.isPending ? (
|
>
|
||||||
<ActivityIndicator size="small" color={theme.colors.foreground} />
|
{mergeMutation.isPending ? (
|
||||||
) : (
|
<ActivityIndicator size="small" color={theme.colors.foreground} />
|
||||||
<Text style={styles.actionButtonText}>Merge to base</Text>
|
) : (
|
||||||
)}
|
<Text style={styles.actionButtonText}>Merge to base</Text>
|
||||||
</Pressable>
|
)}
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
<Pressable
|
<Pressable
|
||||||
testID="changes-action-archive"
|
testID="changes-action-archive"
|
||||||
style={[styles.actionButton, archiveDisabled && styles.actionButtonDisabled]}
|
style={[styles.actionButton, archiveDisabled && styles.actionButtonDisabled]}
|
||||||
@@ -771,6 +779,11 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.debugText} testID="changes-debug-state">
|
||||||
|
mode={diffMode} files={files.length} isGit={String(isGit)} ahead=
|
||||||
|
{gitStatus?.aheadBehind ? String(gitStatus.aheadBehind.ahead) : "null"}
|
||||||
|
</Text>
|
||||||
|
|
||||||
{actionStatus ? <Text style={styles.actionStatusText}>{actionStatus}</Text> : null}
|
{actionStatus ? <Text style={styles.actionStatusText}>{actionStatus}</Text> : null}
|
||||||
{actionError ? <Text style={styles.actionErrorText}>{actionError}</Text> : null}
|
{actionError ? <Text style={styles.actionErrorText}>{actionError}</Text> : null}
|
||||||
{prStatus ? (
|
{prStatus ? (
|
||||||
@@ -866,6 +879,12 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontWeight: theme.fontWeight.medium,
|
fontWeight: theme.fontWeight.medium,
|
||||||
},
|
},
|
||||||
|
debugText: {
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
paddingBottom: theme.spacing[1],
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
},
|
||||||
actionStatusText: {
|
actionStatusText: {
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
paddingBottom: theme.spacing[1],
|
paddingBottom: theme.spacing[1],
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { UnistylesRuntime } from "react-native-unistyles";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import type { CheckoutStatusResponse } from "@server/shared/messages";
|
import type { CheckoutStatusResponse } from "@server/shared/messages";
|
||||||
|
|
||||||
const CHECKOUT_STATUS_STALE_TIME = 15_000;
|
const CHECKOUT_STATUS_STALE_TIME = 15_000;
|
||||||
@@ -22,6 +25,12 @@ export function useCheckoutStatusQuery({ serverId, agentId }: UseCheckoutStatusQ
|
|||||||
const isConnected = useSessionStore(
|
const isConnected = useSessionStore(
|
||||||
(state) => state.sessions[serverId]?.connection.isConnected ?? false
|
(state) => state.sessions[serverId]?.connection.isConnected ?? false
|
||||||
);
|
);
|
||||||
|
const isMobile =
|
||||||
|
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
|
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||||
|
const explorerTab = usePanelStore((state) => state.explorerTab);
|
||||||
|
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: checkoutStatusQueryKey(serverId, agentId),
|
queryKey: checkoutStatusQueryKey(serverId, agentId),
|
||||||
@@ -34,8 +43,18 @@ export function useCheckoutStatusQuery({ serverId, agentId }: UseCheckoutStatusQ
|
|||||||
enabled: !!client && isConnected && !!agentId,
|
enabled: !!client && isConnected && !!agentId,
|
||||||
staleTime: CHECKOUT_STATUS_STALE_TIME,
|
staleTime: CHECKOUT_STATUS_STALE_TIME,
|
||||||
refetchInterval: 10_000,
|
refetchInterval: 10_000,
|
||||||
|
refetchIntervalInBackground: true,
|
||||||
|
refetchOnMount: "always",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Revalidate when sidebar is open with "changes" tab active.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || explorerTab !== "changes" || !agentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void query.refetch();
|
||||||
|
}, [isOpen, explorerTab, agentId, query]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: query.data ?? null,
|
status: query.data ?? null,
|
||||||
isLoading: query.isLoading,
|
isLoading: query.isLoading,
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ describe("self-identification MCP tools", () => {
|
|||||||
const worktree = await createWorktree({
|
const worktree = await createWorktree({
|
||||||
branchName: "self-ident",
|
branchName: "self-ident",
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
|
baseBranch: "main",
|
||||||
worktreeSlug: "self-ident",
|
worktreeSlug: "self-ident",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -176,6 +177,7 @@ describe("self-identification MCP tools", () => {
|
|||||||
const worktree = await createWorktree({
|
const worktree = await createWorktree({
|
||||||
branchName: "self-ident-subdir",
|
branchName: "self-ident-subdir",
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
|
baseBranch: "main",
|
||||||
worktreeSlug: "self-ident-subdir",
|
worktreeSlug: "self-ident-subdir",
|
||||||
});
|
});
|
||||||
const nestedDir = path.join(worktree.worktreePath, "nested");
|
const nestedDir = path.join(worktree.worktreePath, "nested");
|
||||||
|
|||||||
@@ -336,6 +336,12 @@ export async function createAgentMcpServer(
|
|||||||
.describe(
|
.describe(
|
||||||
"Optional git worktree branch name (lowercase alphanumerics + hyphen)."
|
"Optional git worktree branch name (lowercase alphanumerics + hyphen)."
|
||||||
),
|
),
|
||||||
|
baseBranch: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Required when worktreeName is set: the base branch to diff/merge against."
|
||||||
|
),
|
||||||
background: z
|
background: z
|
||||||
.boolean()
|
.boolean()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -422,21 +428,27 @@ export async function createAgentMcpServer(
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
initialMode: string;
|
initialMode: string;
|
||||||
worktreeName?: string;
|
worktreeName?: string;
|
||||||
|
baseBranch?: string;
|
||||||
parentAgentId?: string;
|
parentAgentId?: string;
|
||||||
};
|
};
|
||||||
const {
|
const {
|
||||||
cwd,
|
cwd,
|
||||||
initialMode,
|
initialMode,
|
||||||
worktreeName,
|
worktreeName,
|
||||||
|
baseBranch,
|
||||||
parentAgentId,
|
parentAgentId,
|
||||||
} = topLevelArgs;
|
} = topLevelArgs;
|
||||||
|
|
||||||
resolvedCwd = expandPath(cwd);
|
resolvedCwd = expandPath(cwd);
|
||||||
|
|
||||||
if (worktreeName) {
|
if (worktreeName) {
|
||||||
|
if (!baseBranch) {
|
||||||
|
throw new Error("baseBranch is required when creating a worktree");
|
||||||
|
}
|
||||||
const worktree = await createWorktree({
|
const worktree = await createWorktree({
|
||||||
branchName: worktreeName,
|
branchName: worktreeName,
|
||||||
cwd: resolvedCwd,
|
cwd: resolvedCwd,
|
||||||
|
baseBranch,
|
||||||
worktreeSlug: worktreeName,
|
worktreeSlug: worktreeName,
|
||||||
});
|
});
|
||||||
resolvedCwd = worktree.worktreePath;
|
resolvedCwd = worktree.worktreePath;
|
||||||
|
|||||||
@@ -194,6 +194,9 @@ describe("daemon checkout ship loop", () => {
|
|||||||
expect(status.isGit).toBe(true);
|
expect(status.isGit).toBe(true);
|
||||||
expect(status.isPaseoOwnedWorktree).toBe(true);
|
expect(status.isPaseoOwnedWorktree).toBe(true);
|
||||||
expect(status.repoRoot).toContain(repoDir);
|
expect(status.repoRoot).toContain(repoDir);
|
||||||
|
if (status.isGit) {
|
||||||
|
expect(status.baseRef).toBe("main");
|
||||||
|
}
|
||||||
|
|
||||||
mcpClient = await createMcpClient(ctx.daemon.port, agent.id);
|
mcpClient = await createMcpClient(ctx.daemon.port, agent.id);
|
||||||
const renameResult = (await mcpClient.callTool({
|
const renameResult = (await mcpClient.callTool({
|
||||||
@@ -261,6 +264,13 @@ describe("daemon checkout ship loop", () => {
|
|||||||
expect(mergeResult.error).toBeNull();
|
expect(mergeResult.error).toBeNull();
|
||||||
expect(mergeResult.success).toBe(true);
|
expect(mergeResult.success).toBe(true);
|
||||||
|
|
||||||
|
const statusAfterMerge = await ctx.client.getCheckoutStatus(agent.id);
|
||||||
|
expect(statusAfterMerge.isGit).toBe(true);
|
||||||
|
if (statusAfterMerge.isGit) {
|
||||||
|
expect(statusAfterMerge.baseRef).toBe("main");
|
||||||
|
expect(statusAfterMerge.aheadBehind?.ahead ?? 0).toBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
const baseDiffAfterMerge = await ctx.client.getCheckoutDiff(agent.id, {
|
const baseDiffAfterMerge = await ctx.client.getCheckoutDiff(agent.id, {
|
||||||
mode: "base",
|
mode: "base",
|
||||||
baseRef: "main",
|
baseRef: "main",
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ describe("daemon E2E", () => {
|
|||||||
|
|
||||||
// Initialize git repo
|
// Initialize git repo
|
||||||
const { execSync } = await import("child_process");
|
const { execSync } = await import("child_process");
|
||||||
execSync("git init", { cwd, stdio: "pipe" });
|
execSync("git init -b main", { cwd, stdio: "pipe" });
|
||||||
execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" });
|
execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" });
|
||||||
execSync("git config user.name 'Test'", { cwd, stdio: "pipe" });
|
execSync("git config user.name 'Test'", { cwd, stdio: "pipe" });
|
||||||
|
|
||||||
@@ -571,6 +571,7 @@ describe("daemon E2E", () => {
|
|||||||
createNewBranch: true,
|
createNewBranch: true,
|
||||||
newBranchName: "worktree-test",
|
newBranchName: "worktree-test",
|
||||||
worktreeSlug: "worktree-test",
|
worktreeSlug: "worktree-test",
|
||||||
|
baseBranch: "main",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1587,9 +1587,7 @@ export class Session {
|
|||||||
const createdWorktree = await createWorktree({
|
const createdWorktree = await createWorktree({
|
||||||
branchName: targetBranch,
|
branchName: targetBranch,
|
||||||
cwd,
|
cwd,
|
||||||
baseBranch: normalized.createNewBranch
|
baseBranch: normalized.baseBranch!,
|
||||||
? normalized.baseBranch
|
|
||||||
: undefined,
|
|
||||||
worktreeSlug: normalized.worktreeSlug ?? targetBranch,
|
worktreeSlug: normalized.worktreeSlug ?? targetBranch,
|
||||||
runSetup: false,
|
runSetup: false,
|
||||||
});
|
});
|
||||||
@@ -1598,7 +1596,7 @@ export class Session {
|
|||||||
} else if (normalized.createNewBranch) {
|
} else if (normalized.createNewBranch) {
|
||||||
await this.createBranchFromBase({
|
await this.createBranchFromBase({
|
||||||
cwd,
|
cwd,
|
||||||
baseBranch: normalized.baseBranch ?? "HEAD",
|
baseBranch: normalized.baseBranch!,
|
||||||
newBranchName: normalized.newBranchName!,
|
newBranchName: normalized.newBranchName!,
|
||||||
});
|
});
|
||||||
} else if (normalized.baseBranch) {
|
} else if (normalized.baseBranch) {
|
||||||
@@ -1822,6 +1820,13 @@ export class Session {
|
|||||||
this.assertSafeGitRef(baseBranch, "base branch");
|
this.assertSafeGitRef(baseBranch, "base branch");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (createWorktree && !baseBranch) {
|
||||||
|
throw new Error("Base branch is required when creating a worktree");
|
||||||
|
}
|
||||||
|
if (createNewBranch && !baseBranch) {
|
||||||
|
throw new Error("Base branch is required when creating a new branch");
|
||||||
|
}
|
||||||
|
|
||||||
if (createNewBranch) {
|
if (createNewBranch) {
|
||||||
if (!normalizedBranchName) {
|
if (!normalizedBranchName) {
|
||||||
throw new Error("New branch name is required");
|
throw new Error("New branch name is required");
|
||||||
@@ -2382,7 +2387,7 @@ export class Session {
|
|||||||
isDirty: null,
|
isDirty: null,
|
||||||
baseRef: null,
|
baseRef: null,
|
||||||
aheadBehind: null,
|
aheadBehind: null,
|
||||||
isPaseoOwnedWorktree: null,
|
isPaseoOwnedWorktree: false,
|
||||||
error: { code: "UNKNOWN", message: `Agent not found: ${agentId}` },
|
error: { code: "UNKNOWN", message: `Agent not found: ${agentId}` },
|
||||||
requestId,
|
requestId,
|
||||||
},
|
},
|
||||||
@@ -2412,12 +2417,24 @@ export class Session {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let isPaseoOwnedWorktree = false;
|
if (status.isPaseoOwnedWorktree) {
|
||||||
try {
|
this.emit({
|
||||||
const ownership = await isPaseoOwnedWorktreeCwd(agent.cwd);
|
type: "checkout_status_response",
|
||||||
isPaseoOwnedWorktree = ownership.allowed;
|
payload: {
|
||||||
} catch {
|
agentId,
|
||||||
isPaseoOwnedWorktree = false;
|
cwd: agent.cwd,
|
||||||
|
isGit: true,
|
||||||
|
repoRoot: status.repoRoot ?? null,
|
||||||
|
currentBranch: status.currentBranch ?? null,
|
||||||
|
isDirty: status.isDirty ?? null,
|
||||||
|
baseRef: status.baseRef,
|
||||||
|
aheadBehind: status.aheadBehind ?? null,
|
||||||
|
isPaseoOwnedWorktree: true,
|
||||||
|
error: null,
|
||||||
|
requestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emit({
|
this.emit({
|
||||||
@@ -2431,7 +2448,7 @@ export class Session {
|
|||||||
isDirty: status.isDirty ?? null,
|
isDirty: status.isDirty ?? null,
|
||||||
baseRef: status.baseRef ?? null,
|
baseRef: status.baseRef ?? null,
|
||||||
aheadBehind: status.aheadBehind ?? null,
|
aheadBehind: status.aheadBehind ?? null,
|
||||||
isPaseoOwnedWorktree,
|
isPaseoOwnedWorktree: false,
|
||||||
error: null,
|
error: null,
|
||||||
requestId,
|
requestId,
|
||||||
},
|
},
|
||||||
@@ -2448,7 +2465,7 @@ export class Session {
|
|||||||
isDirty: null,
|
isDirty: null,
|
||||||
baseRef: null,
|
baseRef: null,
|
||||||
aheadBehind: null,
|
aheadBehind: null,
|
||||||
isPaseoOwnedWorktree: null,
|
isPaseoOwnedWorktree: false,
|
||||||
error: this.toCheckoutError(error),
|
error: this.toCheckoutError(error),
|
||||||
requestId,
|
requestId,
|
||||||
},
|
},
|
||||||
@@ -2577,13 +2594,35 @@ export class Session {
|
|||||||
try {
|
try {
|
||||||
const status = await getCheckoutStatus(agent.cwd);
|
const status = await getCheckoutStatus(agent.cwd);
|
||||||
if (!status.isGit) {
|
if (!status.isGit) {
|
||||||
throw new NotGitRepoError(agent.cwd);
|
// `getCheckoutStatus` can return `isGit=false` in transient situations (e.g. cwd lookup
|
||||||
}
|
// running during other git operations). Double-check with git directly before failing.
|
||||||
if (msg.requireCleanTarget && status.isDirty) {
|
try {
|
||||||
throw new Error("Working directory has uncommitted changes.");
|
await execAsync("git rev-parse --is-inside-work-tree", {
|
||||||
|
cwd: agent.cwd,
|
||||||
|
env: READ_ONLY_GIT_ENV,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const details =
|
||||||
|
typeof (error as any)?.stderr === "string"
|
||||||
|
? String((error as any).stderr).trim()
|
||||||
|
: error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: String(error);
|
||||||
|
throw new Error(`Not a git repository: ${agent.cwd}\n${details}`.trim());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let baseRef = msg.baseRef ?? status.baseRef ?? null;
|
if (msg.requireCleanTarget) {
|
||||||
|
const { stdout } = await execAsync("git status --porcelain", {
|
||||||
|
cwd: agent.cwd,
|
||||||
|
env: READ_ONLY_GIT_ENV,
|
||||||
|
});
|
||||||
|
if (stdout.trim().length > 0) {
|
||||||
|
throw new Error("Working directory has uncommitted changes.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let baseRef = msg.baseRef ?? (status.isGit ? status.baseRef : null);
|
||||||
if (!baseRef) {
|
if (!baseRef) {
|
||||||
throw new Error("Base branch is required for merge");
|
throw new Error("Base branch is required for merge");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -966,21 +966,50 @@ const AheadBehindSchema = z.object({
|
|||||||
behind: z.number(),
|
behind: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const CheckoutStatusCommonSchema = z.object({
|
||||||
|
agentId: z.string(),
|
||||||
|
cwd: z.string(),
|
||||||
|
error: CheckoutErrorSchema.nullable(),
|
||||||
|
requestId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const CheckoutStatusNotGitSchema = CheckoutStatusCommonSchema.extend({
|
||||||
|
isGit: z.literal(false),
|
||||||
|
isPaseoOwnedWorktree: z.literal(false),
|
||||||
|
repoRoot: z.null(),
|
||||||
|
currentBranch: z.null(),
|
||||||
|
isDirty: z.null(),
|
||||||
|
baseRef: z.null(),
|
||||||
|
aheadBehind: z.null(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const CheckoutStatusGitNonPaseoSchema = CheckoutStatusCommonSchema.extend({
|
||||||
|
isGit: z.literal(true),
|
||||||
|
isPaseoOwnedWorktree: z.literal(false),
|
||||||
|
repoRoot: z.string(),
|
||||||
|
currentBranch: z.string().nullable(),
|
||||||
|
isDirty: z.boolean(),
|
||||||
|
baseRef: z.string().nullable(),
|
||||||
|
aheadBehind: AheadBehindSchema.nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const CheckoutStatusGitPaseoSchema = CheckoutStatusCommonSchema.extend({
|
||||||
|
isGit: z.literal(true),
|
||||||
|
isPaseoOwnedWorktree: z.literal(true),
|
||||||
|
repoRoot: z.string(),
|
||||||
|
currentBranch: z.string().nullable(),
|
||||||
|
isDirty: z.boolean(),
|
||||||
|
baseRef: z.string(),
|
||||||
|
aheadBehind: AheadBehindSchema.nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
export const CheckoutStatusResponseSchema = z.object({
|
export const CheckoutStatusResponseSchema = z.object({
|
||||||
type: z.literal("checkout_status_response"),
|
type: z.literal("checkout_status_response"),
|
||||||
payload: z.object({
|
payload: z.union([
|
||||||
agentId: z.string(),
|
CheckoutStatusNotGitSchema,
|
||||||
cwd: z.string(),
|
CheckoutStatusGitNonPaseoSchema,
|
||||||
isGit: z.boolean(),
|
CheckoutStatusGitPaseoSchema,
|
||||||
repoRoot: z.string().nullable().optional(),
|
]),
|
||||||
currentBranch: z.string().nullable().optional(),
|
|
||||||
isDirty: z.boolean().nullable().optional(),
|
|
||||||
baseRef: z.string().nullable().optional(),
|
|
||||||
aheadBehind: AheadBehindSchema.nullable().optional(),
|
|
||||||
isPaseoOwnedWorktree: z.boolean().nullable().optional(),
|
|
||||||
error: CheckoutErrorSchema.nullable(),
|
|
||||||
requestId: z.string(),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const CheckoutDiffResponseSchema = z.object({
|
export const CheckoutDiffResponseSchema = z.object({
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
NotGitRepoError,
|
NotGitRepoError,
|
||||||
} from "./checkout-git.js";
|
} from "./checkout-git.js";
|
||||||
import { createWorktree } from "./worktree.js";
|
import { createWorktree } from "./worktree.js";
|
||||||
|
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
|
||||||
|
|
||||||
function initRepo(): { tempDir: string; repoDir: string } {
|
function initRepo(): { tempDir: string; repoDir: string } {
|
||||||
const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "checkout-git-test-")));
|
const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "checkout-git-test-")));
|
||||||
@@ -87,6 +88,7 @@ describe("checkout git utilities", () => {
|
|||||||
const result = await createWorktree({
|
const result = await createWorktree({
|
||||||
branchName: "main",
|
branchName: "main",
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
|
baseBranch: "main",
|
||||||
worktreeSlug: "alpha",
|
worktreeSlug: "alpha",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -117,6 +119,7 @@ describe("checkout git utilities", () => {
|
|||||||
const worktree = await createWorktree({
|
const worktree = await createWorktree({
|
||||||
branchName: "main",
|
branchName: "main",
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
|
baseBranch: "main",
|
||||||
worktreeSlug: "merge",
|
worktreeSlug: "merge",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -138,6 +141,12 @@ describe("checkout git utilities", () => {
|
|||||||
});
|
});
|
||||||
expect(baseContainsFeature).toBeDefined();
|
expect(baseContainsFeature).toBeDefined();
|
||||||
|
|
||||||
|
const statusAfterMerge = await getCheckoutStatus(worktree.worktreePath);
|
||||||
|
expect(statusAfterMerge.isGit).toBe(true);
|
||||||
|
if (statusAfterMerge.isGit) {
|
||||||
|
expect(statusAfterMerge.aheadBehind?.ahead ?? 0).toBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", {
|
const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", {
|
||||||
cwd: worktree.worktreePath,
|
cwd: worktree.worktreePath,
|
||||||
})
|
})
|
||||||
@@ -174,4 +183,96 @@ describe("checkout git utilities", () => {
|
|||||||
mergeToBase(repoDir, { baseRef: "main" })
|
mergeToBase(repoDir, { baseRef: "main" })
|
||||||
).rejects.toBeInstanceOf(MergeConflictError);
|
).rejects.toBeInstanceOf(MergeConflictError);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses stored baseRefName for Paseo worktrees (no heuristics)", async () => {
|
||||||
|
// Create a non-default base branch with a unique commit.
|
||||||
|
execSync("git checkout -b develop", { cwd: repoDir });
|
||||||
|
writeFileSync(join(repoDir, "file.txt"), "develop\n");
|
||||||
|
execSync("git add file.txt", { cwd: repoDir });
|
||||||
|
execSync("git -c commit.gpgsign=false commit -m 'develop change'", { cwd: repoDir });
|
||||||
|
execSync("git checkout main", { cwd: repoDir });
|
||||||
|
|
||||||
|
// Create a worktree/branch based on develop, but keep main as the repo default.
|
||||||
|
const worktree = await createWorktree({
|
||||||
|
branchName: "feature",
|
||||||
|
cwd: repoDir,
|
||||||
|
baseBranch: "develop",
|
||||||
|
worktreeSlug: "feature",
|
||||||
|
});
|
||||||
|
|
||||||
|
writeFileSync(join(worktree.worktreePath, "feature.txt"), "feature\n");
|
||||||
|
execSync("git add feature.txt", { cwd: worktree.worktreePath });
|
||||||
|
execSync("git -c commit.gpgsign=false commit -m 'feature commit'", {
|
||||||
|
cwd: worktree.worktreePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = await getCheckoutStatus(worktree.worktreePath);
|
||||||
|
expect(status.isGit).toBe(true);
|
||||||
|
expect(status.baseRef).toBe("develop");
|
||||||
|
expect(status.aheadBehind?.ahead).toBe(1);
|
||||||
|
|
||||||
|
const baseDiff = await getCheckoutDiff(worktree.worktreePath, { mode: "base" });
|
||||||
|
expect(baseDiff.diff).toContain("feature.txt");
|
||||||
|
expect(baseDiff.diff).not.toContain("file.txt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges to stored baseRefName when baseRef is not provided", async () => {
|
||||||
|
// Create a non-default base branch with a unique commit.
|
||||||
|
execSync("git checkout -b develop", { cwd: repoDir });
|
||||||
|
writeFileSync(join(repoDir, "file.txt"), "develop\n");
|
||||||
|
execSync("git add file.txt", { cwd: repoDir });
|
||||||
|
execSync("git -c commit.gpgsign=false commit -m 'develop change'", { cwd: repoDir });
|
||||||
|
execSync("git checkout main", { cwd: repoDir });
|
||||||
|
|
||||||
|
// Create a Paseo worktree configured to use develop as base.
|
||||||
|
const worktree = await createWorktree({
|
||||||
|
branchName: "feature",
|
||||||
|
cwd: repoDir,
|
||||||
|
baseBranch: "develop",
|
||||||
|
worktreeSlug: "merge-to-develop",
|
||||||
|
});
|
||||||
|
|
||||||
|
writeFileSync(join(worktree.worktreePath, "feature.txt"), "feature\n");
|
||||||
|
execSync("git add feature.txt", { cwd: worktree.worktreePath });
|
||||||
|
execSync("git -c commit.gpgsign=false commit -m 'feature commit'", {
|
||||||
|
cwd: worktree.worktreePath,
|
||||||
|
});
|
||||||
|
const featureCommit = execSync("git rev-parse HEAD", { cwd: worktree.worktreePath })
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// No baseRef passed: should merge into the configured base (develop), not default/main.
|
||||||
|
await mergeToBase(worktree.worktreePath);
|
||||||
|
|
||||||
|
execSync(`git merge-base --is-ancestor ${featureCommit} develop`, {
|
||||||
|
cwd: repoDir,
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
expect(() =>
|
||||||
|
execSync(`git merge-base --is-ancestor ${featureCommit} main`, {
|
||||||
|
cwd: repoDir,
|
||||||
|
stdio: "pipe",
|
||||||
|
})
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws if Paseo worktree base metadata is missing", async () => {
|
||||||
|
const worktree = await createWorktree({
|
||||||
|
branchName: "main",
|
||||||
|
cwd: repoDir,
|
||||||
|
baseBranch: "main",
|
||||||
|
worktreeSlug: "missing-metadata",
|
||||||
|
});
|
||||||
|
|
||||||
|
const metadataPath = getPaseoWorktreeMetadataPath(worktree.worktreePath);
|
||||||
|
rmSync(metadataPath, { force: true });
|
||||||
|
|
||||||
|
await expect(getCheckoutStatus(worktree.worktreePath)).rejects.toThrow(
|
||||||
|
/base/i
|
||||||
|
);
|
||||||
|
await expect(getCheckoutDiff(worktree.worktreePath, { mode: "base" })).rejects.toThrow(
|
||||||
|
/base/i
|
||||||
|
);
|
||||||
|
await expect(mergeToBase(worktree.worktreePath)).rejects.toThrow(/base/i);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { promisify } from "util";
|
|||||||
import { resolve } from "path";
|
import { resolve } from "path";
|
||||||
import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
|
import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
|
||||||
import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js";
|
import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js";
|
||||||
import { detectRepoInfo } from "./worktree.js";
|
import { detectRepoInfo, isPaseoOwnedWorktreeCwd } from "./worktree.js";
|
||||||
|
import { requirePaseoWorktreeBaseRefName } from "./worktree-metadata.js";
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
@@ -43,14 +44,33 @@ export interface AheadBehind {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CheckoutStatus {
|
export interface CheckoutStatus {
|
||||||
isGit: boolean;
|
isGit: false;
|
||||||
repoRoot?: string;
|
|
||||||
currentBranch?: string | null;
|
|
||||||
isDirty?: boolean;
|
|
||||||
baseRef?: string | null;
|
|
||||||
aheadBehind?: AheadBehind | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CheckoutStatusGitNonPaseo = {
|
||||||
|
isGit: true;
|
||||||
|
repoRoot: string;
|
||||||
|
currentBranch: string | null;
|
||||||
|
isDirty: boolean;
|
||||||
|
baseRef: string | null;
|
||||||
|
aheadBehind: AheadBehind | null;
|
||||||
|
isPaseoOwnedWorktree: false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CheckoutStatusGitPaseo = {
|
||||||
|
isGit: true;
|
||||||
|
repoRoot: string;
|
||||||
|
currentBranch: string | null;
|
||||||
|
isDirty: boolean;
|
||||||
|
baseRef: string;
|
||||||
|
aheadBehind: AheadBehind | null;
|
||||||
|
isPaseoOwnedWorktree: true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CheckoutStatusGit = CheckoutStatusGitNonPaseo | CheckoutStatusGitPaseo;
|
||||||
|
|
||||||
|
export type CheckoutStatusResult = CheckoutStatus | CheckoutStatusGit;
|
||||||
|
|
||||||
export interface CheckoutDiffResult {
|
export interface CheckoutDiffResult {
|
||||||
diff: string;
|
diff: string;
|
||||||
structured?: ParsedDiffFile[];
|
structured?: ParsedDiffFile[];
|
||||||
@@ -179,6 +199,25 @@ export async function renameCurrentBranch(
|
|||||||
return { previousBranch, currentBranch };
|
return { previousBranch, currentBranch };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ConfiguredBaseRefForCwd =
|
||||||
|
| { baseRef: null; isPaseoOwnedWorktree: false }
|
||||||
|
| { baseRef: string; isPaseoOwnedWorktree: true };
|
||||||
|
|
||||||
|
async function getConfiguredBaseRefForCwd(
|
||||||
|
cwd: string
|
||||||
|
): Promise<ConfiguredBaseRefForCwd> {
|
||||||
|
const ownership = await isPaseoOwnedWorktreeCwd(cwd);
|
||||||
|
if (!ownership.allowed) {
|
||||||
|
return { baseRef: null, isPaseoOwnedWorktree: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const worktreeRoot = (await getWorktreeRoot(cwd)) ?? cwd;
|
||||||
|
return {
|
||||||
|
baseRef: requirePaseoWorktreeBaseRefName(worktreeRoot),
|
||||||
|
isPaseoOwnedWorktree: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function isWorkingTreeDirty(cwd: string, repoType: "bare" | "normal"): Promise<boolean> {
|
async function isWorkingTreeDirty(cwd: string, repoType: "bare" | "normal"): Promise<boolean> {
|
||||||
if (repoType === "bare") {
|
if (repoType === "bare") {
|
||||||
return false;
|
return false;
|
||||||
@@ -198,7 +237,21 @@ async function resolveBaseRef(repoRoot: string): Promise<string | null> {
|
|||||||
});
|
});
|
||||||
const ref = stdout.trim();
|
const ref = stdout.trim();
|
||||||
if (ref) {
|
if (ref) {
|
||||||
return ref.replace(/^refs\/remotes\//, "");
|
// Prefer a local branch name (e.g. "main") over the remote-tracking ref (e.g. "origin/main")
|
||||||
|
// so that status/diff/merge all operate against the same base ref.
|
||||||
|
const remoteShort = ref.replace(/^refs\/remotes\//, "");
|
||||||
|
const localName = remoteShort.startsWith("origin/")
|
||||||
|
? remoteShort.slice("origin/".length)
|
||||||
|
: remoteShort;
|
||||||
|
try {
|
||||||
|
await execAsync(`git show-ref --verify --quiet refs/heads/${localName}`, {
|
||||||
|
cwd: repoRoot,
|
||||||
|
env: READ_ONLY_GIT_ENV,
|
||||||
|
});
|
||||||
|
return localName;
|
||||||
|
} catch {
|
||||||
|
return remoteShort;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
@@ -223,12 +276,17 @@ async function resolveBaseRef(repoRoot: string): Promise<string | null> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeLocalBranchRefName(input: string): string {
|
||||||
|
return input.startsWith("origin/") ? input.slice("origin/".length) : input;
|
||||||
|
}
|
||||||
|
|
||||||
async function getAheadBehind(cwd: string, baseRef: string, currentBranch: string): Promise<AheadBehind | null> {
|
async function getAheadBehind(cwd: string, baseRef: string, currentBranch: string): Promise<AheadBehind | null> {
|
||||||
if (!baseRef || !currentBranch || baseRef === currentBranch) {
|
const normalizedBaseRef = normalizeLocalBranchRefName(baseRef);
|
||||||
|
if (!normalizedBaseRef || !currentBranch || normalizedBaseRef === currentBranch) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const { stdout } = await execAsync(
|
const { stdout } = await execAsync(
|
||||||
`git rev-list --left-right --count ${baseRef}...${currentBranch}`,
|
`git rev-list --left-right --count ${normalizedBaseRef}...${currentBranch}`,
|
||||||
{ cwd, env: READ_ONLY_GIT_ENV }
|
{ cwd, env: READ_ONLY_GIT_ENV }
|
||||||
);
|
);
|
||||||
const [behindRaw, aheadRaw] = stdout.trim().split(/\s+/);
|
const [behindRaw, aheadRaw] = stdout.trim().split(/\s+/);
|
||||||
@@ -268,7 +326,7 @@ async function getUntrackedDiff(cwd: string): Promise<string> {
|
|||||||
return untrackedDiff;
|
return untrackedDiff;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCheckoutStatus(cwd: string): Promise<CheckoutStatus> {
|
export async function getCheckoutStatus(cwd: string): Promise<CheckoutStatusResult> {
|
||||||
let repoInfo: Awaited<ReturnType<typeof detectRepoInfo>>;
|
let repoInfo: Awaited<ReturnType<typeof detectRepoInfo>>;
|
||||||
try {
|
try {
|
||||||
repoInfo = await detectRepoInfo(cwd);
|
repoInfo = await detectRepoInfo(cwd);
|
||||||
@@ -281,10 +339,22 @@ export async function getCheckoutStatus(cwd: string): Promise<CheckoutStatus> {
|
|||||||
|
|
||||||
const currentBranch = await getCurrentBranch(cwd);
|
const currentBranch = await getCurrentBranch(cwd);
|
||||||
const isDirty = await isWorkingTreeDirty(cwd, repoInfo.type);
|
const isDirty = await isWorkingTreeDirty(cwd, repoInfo.type);
|
||||||
const baseRef = await resolveBaseRef(repoInfo.path);
|
const configured = await getConfiguredBaseRefForCwd(cwd);
|
||||||
const aheadBehind = baseRef && currentBranch
|
const baseRef = configured.baseRef ?? (await resolveBaseRef(repoInfo.path));
|
||||||
? await getAheadBehind(cwd, baseRef, currentBranch)
|
const aheadBehind =
|
||||||
: null;
|
baseRef && currentBranch ? await getAheadBehind(cwd, baseRef, currentBranch) : null;
|
||||||
|
|
||||||
|
if (configured.isPaseoOwnedWorktree) {
|
||||||
|
return {
|
||||||
|
isGit: true,
|
||||||
|
repoRoot: repoInfo.path,
|
||||||
|
currentBranch,
|
||||||
|
isDirty,
|
||||||
|
baseRef: configured.baseRef,
|
||||||
|
aheadBehind,
|
||||||
|
isPaseoOwnedWorktree: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isGit: true,
|
isGit: true,
|
||||||
@@ -293,6 +363,7 @@ export async function getCheckoutStatus(cwd: string): Promise<CheckoutStatus> {
|
|||||||
isDirty,
|
isDirty,
|
||||||
baseRef,
|
baseRef,
|
||||||
aheadBehind,
|
aheadBehind,
|
||||||
|
isPaseoOwnedWorktree: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,12 +382,16 @@ export async function getCheckoutDiff(
|
|||||||
const untrackedDiff = await getUntrackedDiff(cwd);
|
const untrackedDiff = await getUntrackedDiff(cwd);
|
||||||
diff = trackedDiff + untrackedDiff;
|
diff = trackedDiff + untrackedDiff;
|
||||||
} else {
|
} else {
|
||||||
|
const configured = await getConfiguredBaseRefForCwd(cwd);
|
||||||
const repoInfo = await detectRepoInfo(cwd);
|
const repoInfo = await detectRepoInfo(cwd);
|
||||||
const baseRef = compare.baseRef ?? (await resolveBaseRef(repoInfo.path));
|
const baseRef = configured.baseRef ?? compare.baseRef ?? (await resolveBaseRef(repoInfo.path));
|
||||||
if (!baseRef) {
|
if (!baseRef) {
|
||||||
diff = "";
|
diff = "";
|
||||||
|
} else if (configured.isPaseoOwnedWorktree && compare.baseRef && compare.baseRef !== baseRef) {
|
||||||
|
throw new Error(`Base ref mismatch: expected ${baseRef}, got ${compare.baseRef}`);
|
||||||
} else {
|
} else {
|
||||||
const { stdout } = await execAsync(`git diff ${baseRef}...HEAD`, {
|
const normalizedBaseRef = normalizeLocalBranchRefName(baseRef);
|
||||||
|
const { stdout } = await execAsync(`git diff ${normalizedBaseRef}...HEAD`, {
|
||||||
cwd,
|
cwd,
|
||||||
env: READ_ONLY_GIT_ENV,
|
env: READ_ONLY_GIT_ENV,
|
||||||
});
|
});
|
||||||
@@ -350,17 +425,19 @@ export async function commitAll(cwd: string, message: string): Promise<void> {
|
|||||||
export async function mergeToBase(cwd: string, options: MergeToBaseOptions = {}): Promise<void> {
|
export async function mergeToBase(cwd: string, options: MergeToBaseOptions = {}): Promise<void> {
|
||||||
const repoInfo = await requireRepoInfo(cwd);
|
const repoInfo = await requireRepoInfo(cwd);
|
||||||
const currentBranch = await getCurrentBranch(cwd);
|
const currentBranch = await getCurrentBranch(cwd);
|
||||||
const baseRef = options.baseRef ?? (await resolveBaseRef(repoInfo.path));
|
const configured = await getConfiguredBaseRefForCwd(cwd);
|
||||||
|
const baseRef = configured.baseRef ?? options.baseRef ?? (await resolveBaseRef(repoInfo.path));
|
||||||
if (!baseRef) {
|
if (!baseRef) {
|
||||||
throw new Error("Unable to determine base branch for merge");
|
throw new Error("Unable to determine base branch for merge");
|
||||||
}
|
}
|
||||||
|
if (configured.isPaseoOwnedWorktree && options.baseRef && options.baseRef !== baseRef) {
|
||||||
|
throw new Error(`Base ref mismatch: expected ${baseRef}, got ${options.baseRef}`);
|
||||||
|
}
|
||||||
if (!currentBranch) {
|
if (!currentBranch) {
|
||||||
throw new Error("Unable to determine current branch for merge");
|
throw new Error("Unable to determine current branch for merge");
|
||||||
}
|
}
|
||||||
let normalizedBaseRef = baseRef;
|
let normalizedBaseRef = baseRef;
|
||||||
if (normalizedBaseRef.startsWith("origin/")) {
|
normalizedBaseRef = normalizeLocalBranchRefName(normalizedBaseRef);
|
||||||
normalizedBaseRef = normalizedBaseRef.slice("origin/".length);
|
|
||||||
}
|
|
||||||
if (normalizedBaseRef === currentBranch) {
|
if (normalizedBaseRef === currentBranch) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -523,19 +600,24 @@ export async function createPullRequest(
|
|||||||
|
|
||||||
const repoInfo = await detectRepoInfo(cwd);
|
const repoInfo = await detectRepoInfo(cwd);
|
||||||
const head = options.head ?? (await getCurrentBranch(cwd));
|
const head = options.head ?? (await getCurrentBranch(cwd));
|
||||||
const base = options.base ?? (await resolveBaseRef(repoInfo.path));
|
const configured = await getConfiguredBaseRefForCwd(cwd);
|
||||||
|
const base = configured.baseRef ?? options.base ?? (await resolveBaseRef(repoInfo.path));
|
||||||
if (!head) {
|
if (!head) {
|
||||||
throw new Error("Unable to determine head branch for PR");
|
throw new Error("Unable to determine head branch for PR");
|
||||||
}
|
}
|
||||||
if (!base) {
|
if (!base) {
|
||||||
throw new Error("Unable to determine base branch for PR");
|
throw new Error("Unable to determine base branch for PR");
|
||||||
}
|
}
|
||||||
|
const normalizedBase = normalizeLocalBranchRefName(base);
|
||||||
|
if (configured.isPaseoOwnedWorktree && options.base && options.base !== base) {
|
||||||
|
throw new Error(`Base ref mismatch: expected ${base}, got ${options.base}`);
|
||||||
|
}
|
||||||
|
|
||||||
await execAsync(`git push -u origin ${head}`, { cwd });
|
await execAsync(`git push -u origin ${head}`, { cwd });
|
||||||
|
|
||||||
const args = ["api", "-X", "POST", `repos/${repo}/pulls`, "-f", `title=${options.title}`];
|
const args = ["api", "-X", "POST", `repos/${repo}/pulls`, "-f", `title=${options.title}`];
|
||||||
args.push("-f", `head=${head}`);
|
args.push("-f", `head=${head}`);
|
||||||
args.push("-f", `base=${base}`);
|
args.push("-f", `base=${normalizedBase}`);
|
||||||
if (options.body) {
|
if (options.body) {
|
||||||
args.push("-f", `body=${options.body}`);
|
args.push("-f", `body=${options.body}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
||||||
|
import { isAbsolute, join, resolve } from "path";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const PaseoWorktreeMetadataSchema = z.object({
|
||||||
|
version: z.literal(1),
|
||||||
|
baseRefName: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PaseoWorktreeMetadata = z.infer<typeof PaseoWorktreeMetadataSchema>;
|
||||||
|
|
||||||
|
function getGitDirForWorktreeRoot(worktreeRoot: string): string {
|
||||||
|
const gitPath = join(worktreeRoot, ".git");
|
||||||
|
if (!existsSync(gitPath)) {
|
||||||
|
throw new Error(`Not a git repository: ${worktreeRoot}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// In a worktree checkout, `.git` is a file containing `gitdir: <path>`.
|
||||||
|
// In a normal checkout, `.git` is a directory.
|
||||||
|
try {
|
||||||
|
const gitFileContent = readFileSync(gitPath, "utf8");
|
||||||
|
const match = gitFileContent.match(/gitdir:\s*(.+)/);
|
||||||
|
if (match?.[1]) {
|
||||||
|
const raw = match[1].trim();
|
||||||
|
return isAbsolute(raw) ? raw : resolve(worktreeRoot, raw);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If `.git` is a directory, readFileSync will throw; fall through.
|
||||||
|
}
|
||||||
|
|
||||||
|
return gitPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPaseoWorktreeMetadataPath(worktreeRoot: string): string {
|
||||||
|
const gitDir = getGitDirForWorktreeRoot(worktreeRoot);
|
||||||
|
return join(gitDir, "paseo", "worktree.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeBaseRefName(input: string): string {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new Error("Base branch is required");
|
||||||
|
}
|
||||||
|
if (trimmed.startsWith("origin/")) {
|
||||||
|
return trimmed.slice("origin/".length);
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writePaseoWorktreeMetadata(
|
||||||
|
worktreeRoot: string,
|
||||||
|
options: { baseRefName: string }
|
||||||
|
): void {
|
||||||
|
const baseRefName = normalizeBaseRefName(options.baseRefName);
|
||||||
|
if (baseRefName === "HEAD") {
|
||||||
|
throw new Error("Base branch cannot be HEAD");
|
||||||
|
}
|
||||||
|
if (baseRefName.includes("..") || baseRefName.includes("@{")) {
|
||||||
|
throw new Error(`Invalid base branch: ${baseRefName}`);
|
||||||
|
}
|
||||||
|
if (!/^[0-9A-Za-z._/-]+$/.test(baseRefName)) {
|
||||||
|
throw new Error(`Invalid base branch: ${baseRefName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot);
|
||||||
|
mkdirSync(join(getGitDirForWorktreeRoot(worktreeRoot), "paseo"), { recursive: true });
|
||||||
|
const metadata: PaseoWorktreeMetadata = { version: 1, baseRefName };
|
||||||
|
writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readPaseoWorktreeMetadata(worktreeRoot: string): PaseoWorktreeMetadata | null {
|
||||||
|
const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot);
|
||||||
|
if (!existsSync(metadataPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(readFileSync(metadataPath, "utf8"));
|
||||||
|
return PaseoWorktreeMetadataSchema.parse(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requirePaseoWorktreeBaseRefName(worktreeRoot: string): string {
|
||||||
|
const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot);
|
||||||
|
const metadata = readPaseoWorktreeMetadata(worktreeRoot);
|
||||||
|
if (!metadata) {
|
||||||
|
throw new Error(`Missing Paseo worktree base metadata: ${metadataPath}`);
|
||||||
|
}
|
||||||
|
return metadata.baseRefName;
|
||||||
|
}
|
||||||
@@ -3,9 +3,11 @@ import {
|
|||||||
createWorktree,
|
createWorktree,
|
||||||
deletePaseoWorktree,
|
deletePaseoWorktree,
|
||||||
ensurePaseoIgnored,
|
ensurePaseoIgnored,
|
||||||
|
isPaseoOwnedWorktreeCwd,
|
||||||
listPaseoWorktrees,
|
listPaseoWorktrees,
|
||||||
slugify,
|
slugify,
|
||||||
} from "./worktree";
|
} from "./worktree";
|
||||||
|
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 { join } from "path";
|
||||||
@@ -38,6 +40,7 @@ describe("createWorktree", () => {
|
|||||||
const result = await createWorktree({
|
const result = await createWorktree({
|
||||||
branchName: "main",
|
branchName: "main",
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
|
baseBranch: "main",
|
||||||
worktreeSlug: "hello-world",
|
worktreeSlug: "hello-world",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,6 +49,41 @@ describe("createWorktree", () => {
|
|||||||
);
|
);
|
||||||
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);
|
||||||
|
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
|
||||||
|
expect(existsSync(metadataPath)).toBe(true);
|
||||||
|
const metadata = JSON.parse(
|
||||||
|
readFileSync(metadataPath, "utf8")
|
||||||
|
);
|
||||||
|
expect(metadata).toMatchObject({ version: 1, baseRefName: "main" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects paseo-owned worktrees across realpath differences (macOS /var vs /private/var)", async () => {
|
||||||
|
// Intentionally create repo using the non-realpath tmpdir() variant (often /var/... on macOS).
|
||||||
|
const varTempDir = mkdtempSync(join(tmpdir(), "worktree-realpath-test-"));
|
||||||
|
const privateTempDir = realpathSync(varTempDir);
|
||||||
|
const varRepoDir = join(varTempDir, "test-repo");
|
||||||
|
execSync(`mkdir -p ${varRepoDir}`);
|
||||||
|
execSync("git init -b main", { cwd: varRepoDir });
|
||||||
|
execSync("git config user.email 'test@test.com'", { cwd: varRepoDir });
|
||||||
|
execSync("git config user.name 'Test'", { cwd: varRepoDir });
|
||||||
|
execSync("echo 'hello' > file.txt", { cwd: varRepoDir });
|
||||||
|
execSync("git add .", { cwd: varRepoDir });
|
||||||
|
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: varRepoDir });
|
||||||
|
|
||||||
|
const result = await createWorktree({
|
||||||
|
branchName: "main",
|
||||||
|
cwd: varRepoDir,
|
||||||
|
baseBranch: "main",
|
||||||
|
worktreeSlug: "realpath-test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const privateWorktreePath = join(privateTempDir, "test-repo", ".paseo", "worktrees", "realpath-test");
|
||||||
|
expect(existsSync(privateWorktreePath)).toBe(true);
|
||||||
|
|
||||||
|
const ownership = await isPaseoOwnedWorktreeCwd(privateWorktreePath);
|
||||||
|
expect(ownership.allowed).toBe(true);
|
||||||
|
|
||||||
|
rmSync(varTempDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates a worktree with a new branch", async () => {
|
it("creates a worktree with a new branch", async () => {
|
||||||
@@ -64,6 +102,11 @@ describe("createWorktree", () => {
|
|||||||
// Verify branch was created
|
// Verify branch was created
|
||||||
const branches = execSync("git branch", { cwd: repoDir }).toString();
|
const branches = execSync("git branch", { cwd: repoDir }).toString();
|
||||||
expect(branches).toContain("feature-branch");
|
expect(branches).toContain("feature-branch");
|
||||||
|
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
|
||||||
|
const metadata = JSON.parse(
|
||||||
|
readFileSync(metadataPath, "utf8")
|
||||||
|
);
|
||||||
|
expect(metadata).toMatchObject({ version: 1, baseRefName: "main" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fails with invalid branch name", async () => {
|
it("fails with invalid branch name", async () => {
|
||||||
@@ -71,6 +114,7 @@ describe("createWorktree", () => {
|
|||||||
createWorktree({
|
createWorktree({
|
||||||
branchName: "INVALID_UPPERCASE",
|
branchName: "INVALID_UPPERCASE",
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
|
baseBranch: "main",
|
||||||
worktreeSlug: "test",
|
worktreeSlug: "test",
|
||||||
})
|
})
|
||||||
).rejects.toThrow("Invalid branch name");
|
).rejects.toThrow("Invalid branch name");
|
||||||
@@ -83,6 +127,7 @@ describe("createWorktree", () => {
|
|||||||
const result = await createWorktree({
|
const result = await createWorktree({
|
||||||
branchName: "main",
|
branchName: "main",
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
|
baseBranch: "main",
|
||||||
worktreeSlug: "hello",
|
worktreeSlug: "hello",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -104,6 +149,7 @@ describe("createWorktree", () => {
|
|||||||
const result = await createWorktree({
|
const result = await createWorktree({
|
||||||
branchName: "main",
|
branchName: "main",
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
|
baseBranch: "main",
|
||||||
worktreeSlug: "hello",
|
worktreeSlug: "hello",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -130,6 +176,7 @@ describe("createWorktree", () => {
|
|||||||
const result = await createWorktree({
|
const result = await createWorktree({
|
||||||
branchName: "main",
|
branchName: "main",
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
|
baseBranch: "main",
|
||||||
worktreeSlug: "setup-test",
|
worktreeSlug: "setup-test",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -157,6 +204,7 @@ describe("createWorktree", () => {
|
|||||||
const result = await createWorktree({
|
const result = await createWorktree({
|
||||||
branchName: "main",
|
branchName: "main",
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
|
baseBranch: "main",
|
||||||
worktreeSlug: "no-setup-test",
|
worktreeSlug: "no-setup-test",
|
||||||
runSetup: false,
|
runSetup: false,
|
||||||
});
|
});
|
||||||
@@ -186,6 +234,7 @@ describe("createWorktree", () => {
|
|||||||
createWorktree({
|
createWorktree({
|
||||||
branchName: "main",
|
branchName: "main",
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
|
baseBranch: "main",
|
||||||
worktreeSlug: "fail-test",
|
worktreeSlug: "fail-test",
|
||||||
})
|
})
|
||||||
).rejects.toThrow("Worktree setup command failed");
|
).rejects.toThrow("Worktree setup command failed");
|
||||||
@@ -220,11 +269,13 @@ describe("paseo worktree manager", () => {
|
|||||||
const first = await createWorktree({
|
const first = await createWorktree({
|
||||||
branchName: "main",
|
branchName: "main",
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
|
baseBranch: "main",
|
||||||
worktreeSlug: "alpha",
|
worktreeSlug: "alpha",
|
||||||
});
|
});
|
||||||
const second = await createWorktree({
|
const second = await createWorktree({
|
||||||
branchName: "main",
|
branchName: "main",
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
|
baseBranch: "main",
|
||||||
worktreeSlug: "beta",
|
worktreeSlug: "beta",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { promisify } from "util";
|
|||||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
||||||
import { join, basename, dirname, resolve, sep } from "path";
|
import { join, basename, dirname, resolve, sep } from "path";
|
||||||
import { createNameId } from "mnemonic-id";
|
import { createNameId } from "mnemonic-id";
|
||||||
|
import { normalizeBaseRefName, writePaseoWorktreeMetadata } from "./worktree-metadata.js";
|
||||||
|
|
||||||
interface PaseoConfig {
|
interface PaseoConfig {
|
||||||
worktree?: {
|
worktree?: {
|
||||||
@@ -63,7 +64,7 @@ export type PaseoWorktreeOwnership = {
|
|||||||
interface CreateWorktreeOptions {
|
interface CreateWorktreeOptions {
|
||||||
branchName: string;
|
branchName: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
baseBranch?: string;
|
baseBranch: string;
|
||||||
worktreeSlug?: string;
|
worktreeSlug?: string;
|
||||||
runSetup?: boolean;
|
runSetup?: boolean;
|
||||||
}
|
}
|
||||||
@@ -494,6 +495,19 @@ export async function createWorktree({
|
|||||||
// Ensure .paseo exists and is ignored
|
// Ensure .paseo exists and is ignored
|
||||||
ensurePaseoIgnoredForRepo(repoInfo);
|
ensurePaseoIgnoredForRepo(repoInfo);
|
||||||
|
|
||||||
|
const normalizedBaseBranch = baseBranch ? normalizeBaseRefName(baseBranch) : "";
|
||||||
|
if (!normalizedBaseBranch) {
|
||||||
|
throw new Error("Base branch is required when creating a Paseo worktree");
|
||||||
|
}
|
||||||
|
if (normalizedBaseBranch === "HEAD") {
|
||||||
|
throw new Error("Base branch cannot be HEAD when creating a Paseo worktree");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await execAsync(`git rev-parse --verify ${normalizedBaseBranch}`, { cwd: repoInfo.path });
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Base branch not found: ${normalizedBaseBranch}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Determine worktree directory based on repo type
|
// Determine worktree directory based on repo type
|
||||||
let worktreePath: string;
|
let worktreePath: string;
|
||||||
const desiredSlug = worktreeSlug || generateWorktreeSlug();
|
const desiredSlug = worktreeSlug || generateWorktreeSlug();
|
||||||
@@ -516,7 +530,7 @@ export async function createWorktree({
|
|||||||
// Always create a new branch for the worktree
|
// Always create a new branch for the worktree
|
||||||
// If branchName already exists, use it as base and create worktree-slug as branch name
|
// If branchName already exists, use it as base and create worktree-slug as branch name
|
||||||
// If branchName doesn't exist, create it from baseBranch
|
// If branchName doesn't exist, create it from baseBranch
|
||||||
const base = branchExists ? branchName : (baseBranch ?? "HEAD");
|
const base = branchExists ? branchName : normalizedBaseBranch;
|
||||||
const candidateBranch = branchExists ? desiredSlug : branchName;
|
const candidateBranch = branchExists ? desiredSlug : branchName;
|
||||||
|
|
||||||
// Find unique branch name if collision
|
// Find unique branch name if collision
|
||||||
@@ -548,6 +562,8 @@ export async function createWorktree({
|
|||||||
await execAsync(command, { cwd: repoInfo.path });
|
await execAsync(command, { cwd: repoInfo.path });
|
||||||
worktreePath = finalWorktreePath;
|
worktreePath = finalWorktreePath;
|
||||||
|
|
||||||
|
writePaseoWorktreeMetadata(worktreePath, { baseRefName: normalizedBaseBranch });
|
||||||
|
|
||||||
if (runSetup) {
|
if (runSetup) {
|
||||||
await runWorktreeSetupCommands({
|
await runWorktreeSetupCommands({
|
||||||
repoRoot: repoInfo.path,
|
repoRoot: repoInfo.path,
|
||||||
|
|||||||
Reference in New Issue
Block a user