Unverified 提交 186f0d60 authored 作者: Adekunle James Adeniji's avatar Adekunle James Adeniji 提交者: GitHub

Add UI for Git Pull Support (#2342)

<!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2342"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds a pull workflow and consolidates branch operations into a single actions menu. > > - UI: Replace standalone buttons with `Branch actions` dropdown (`GithubBranchManager.tsx`) containing `Create new branch`, `Refresh branches`, and new `Git pull`; disables controls while pulling > - IPC: New `github:pull` contract and handler that pulls from `origin` on current branch, uses auth token, tolerates missing remote branch, and reloads branches (`src/ipc/types/github.ts`, `src/ipc/handlers/git_branch_handlers.ts`) > - E2E: Update flows to use `branch-actions-menu-trigger`; add pull test and snapshots; factor `configureGitUser()` helper (`e2e-tests/...`) > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 40bb9e7cd72308acf34563e9758884d2b0c2cd4e. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Adds Git pull support and consolidates branch actions into a single “Branch actions” menu, making it easy to pull remote changes from the UI. Includes IPC wiring and an end-to-end test. - **New Features** - Added “Branch actions” dropdown with Create branch, Refresh branches, and Git pull. - Git pull action with loading state and success/error toasts. - New IPC contract and handler (github:pull) that pulls from origin and tolerates missing remote branch. - **Refactors** - Replaced separate buttons with a dropdown in GithubBranchManager and updated test IDs. - Added configureGitUser helper and new e2e test for pulling from remote. - Updated snapshots to reflect the new menu. <sup>Written for commit 40bb9e7cd72308acf34563e9758884d2b0c2cd4e. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. -->
上级 eb9f9162
...@@ -34,9 +34,12 @@ test.describe("Git Collaboration", () => { ...@@ -34,9 +34,12 @@ test.describe("Git Collaboration", () => {
await po.getTitleBarAppNameButton().click(); // Open Publish Panel await po.getTitleBarAppNameButton().click(); // Open Publish Panel
// Wait for BranchManager to appear // Wait for BranchManager to appear
await expect(po.page.getByTestId("create-branch-trigger")).toBeVisible({ await expect(
po.page.getByTestId("branch-actions-menu-trigger"),
).toBeVisible({
timeout: 10000, timeout: 10000,
}); });
await po.page.getByTestId("branch-actions-menu-trigger").click();
await po.page.getByTestId("create-branch-trigger").click(); await po.page.getByTestId("create-branch-trigger").click();
await po.page.getByTestId("new-branch-name-input").fill(featureBranch); await po.page.getByTestId("new-branch-name-input").fill(featureBranch);
await po.page.getByTestId("create-branch-submit-button").click(); await po.page.getByTestId("create-branch-submit-button").click();
...@@ -58,6 +61,7 @@ test.describe("Git Collaboration", () => { ...@@ -58,6 +61,7 @@ test.describe("Git Collaboration", () => {
); );
const featureBranch2 = "feature-2"; const featureBranch2 = "feature-2";
await po.page.getByTestId("branch-actions-menu-trigger").click();
await po.page.getByTestId("create-branch-trigger").click(); await po.page.getByTestId("create-branch-trigger").click();
await po.page.getByTestId("new-branch-name-input").fill(featureBranch2); await po.page.getByTestId("new-branch-name-input").fill(featureBranch2);
// Select source branch 'main' explicitly (though it defaults to HEAD which is main) // Select source branch 'main' explicitly (though it defaults to HEAD which is main)
...@@ -124,9 +128,7 @@ test.describe("Git Collaboration", () => { ...@@ -124,9 +128,7 @@ test.describe("Git Collaboration", () => {
const featureContent = "Content from feature-1 branch"; const featureContent = "Content from feature-1 branch";
fs.writeFileSync(mergeTestFilePath, featureContent); fs.writeFileSync(mergeTestFilePath, featureContent);
// Configure git user for commit // Configure git user for commit
execSync("git config user.email 'test@example.com'", { cwd: appPath }); await po.configureGitUser();
execSync("git config user.name 'Test User'", { cwd: appPath });
execSync("git config commit.gpgsign false", { cwd: appPath });
execSync( execSync(
`git add ${mergeTestFile} && git commit -m "Add merge test file"`, `git add ${mergeTestFile} && git commit -m "Add merge test file"`,
{ {
...@@ -188,6 +190,68 @@ test.describe("Git Collaboration", () => { ...@@ -188,6 +190,68 @@ test.describe("Git Collaboration", () => {
await po.page.keyboard.press("Escape"); await po.page.keyboard.press("Escape");
}); });
test("should pull changes from remote", async ({ po }) => {
await po.setUp({ disableNativeGit: false });
await po.sendPrompt("tc=basic");
await po.getTitleBarAppNameButton().click();
await po.githubConnector.connect();
// Create a new repo to start fresh
const repoName = "test-git-pull-" + Date.now();
await po.githubConnector.fillCreateRepoName(repoName);
await po.githubConnector.clickCreateRepoButton();
// Wait for repo to be connected
await expect(po.page.getByTestId("github-connected-repo")).toBeVisible({
timeout: Timeout.MEDIUM,
});
const appPath = await po.getCurrentAppPath();
if (!appPath) throw new Error("App path not found");
// Configure git user
await po.configureGitUser();
// Create a file locally
const testFile = "pull-test.txt";
const testFilePath = path.join(appPath, testFile);
const fileContent = "Initial content";
fs.writeFileSync(testFilePath, fileContent);
execSync(`git add ${testFile} && git commit -m "Add pull test file"`, {
cwd: appPath,
});
// Go to publish panel
await po.goToChatTab();
await po.getTitleBarAppNameButton().click();
// Open the branch actions dropdown
await expect(
po.page.getByTestId("branch-actions-menu-trigger"),
).toBeVisible({
timeout: 10000,
});
// Test git pull - should succeed with no remote changes
await po.page.getByTestId("branch-actions-menu-trigger").click();
await po.page.getByTestId("git-pull-button").click();
// Wait for success toast
await po.waitForToast("success", 10000);
// Verify the file still exists (pull succeeded)
expect(fs.existsSync(testFilePath)).toBe(true);
expect(fs.readFileSync(testFilePath, "utf-8")).toBe(fileContent);
// Verify git status is clean
const gitStatus = execSync("git status --porcelain", {
cwd: appPath,
encoding: "utf8",
}).trim();
expect(gitStatus).toBe("");
});
test("should invite and remove collaborators", async ({ po }) => { test("should invite and remove collaborators", async ({ po }) => {
await po.setUp(); await po.setUp();
await po.sendPrompt("tc=basic"); await po.sendPrompt("tc=basic");
......
...@@ -1129,6 +1129,27 @@ export class PageObject { ...@@ -1129,6 +1129,27 @@ export class PageObject {
return this.getAppPath({ appName: currentAppName }); return this.getAppPath({ appName: currentAppName });
} }
async configureGitUser({
email = "test@example.com",
name = "Test User",
disableGpgSign = true,
}: {
email?: string;
name?: string;
disableGpgSign?: boolean;
} = {}) {
const appPath = await this.getCurrentAppPath();
if (!appPath) {
throw new Error("App path not found");
}
execSync(`git config user.email '${email}'`, { cwd: appPath });
execSync(`git config user.name '${name}'`, { cwd: appPath });
if (disableGpgSign) {
execSync("git config commit.gpgsign false", { cwd: appPath });
}
}
getAppPath({ appName }: { appName: string }) { getAppPath({ appName }: { appName: string }) {
return path.join(this.userDataDir, "dyad-apps", appName); return path.join(this.userDataDir, "dyad-apps", appName);
} }
......
...@@ -3,9 +3,7 @@ ...@@ -3,9 +3,7 @@
- combobox: - combobox:
- img - img
- text: "Branch: main" - text: "Branch: main"
- button "Refresh branches": - button "Branch actions":
- img
- button "Create new branch":
- img - img
- img - img
- heading "Branches" [level=3] - heading "Branches" [level=3]
......
...@@ -3,9 +3,7 @@ ...@@ -3,9 +3,7 @@
- combobox: - combobox:
- img - img
- text: "Branch: new-branch" - text: "Branch: new-branch"
- button "Refresh branches": - button "Branch actions":
- img
- button "Create new branch":
- img - img
- img - img
- heading "Branches" [level=3] - heading "Branches" [level=3]
......
...@@ -3,9 +3,7 @@ ...@@ -3,9 +3,7 @@
- combobox: - combobox:
- img - img
- text: "Branch: main" - text: "Branch: main"
- button "Refresh branches": - button "Branch actions":
- img
- button "Create new branch":
- img - img
- img - img
- heading "Branches" [level=3] - heading "Branches" [level=3]
......
...@@ -3,9 +3,7 @@ ...@@ -3,9 +3,7 @@
- combobox: - combobox:
- img - img
- text: "Branch: new-branch" - text: "Branch: new-branch"
- button "Refresh branches": - button "Branch actions":
- img
- button "Create new branch":
- img - img
- img - img
- heading "Branches" [level=3] - heading "Branches" [level=3]
......
...@@ -3,9 +3,7 @@ ...@@ -3,9 +3,7 @@
- combobox: - combobox:
- img - img
- text: "Branch: main" - text: "Branch: main"
- button "Refresh branches": - button "Branch actions":
- img
- button "Create new branch":
- img - img
- img - img
- heading "Branches" [level=3] - heading "Branches" [level=3]
......
...@@ -3,9 +3,7 @@ ...@@ -3,9 +3,7 @@
- combobox: - combobox:
- img - img
- text: "Branch: main" - text: "Branch: main"
- button "Refresh branches": - button "Branch actions":
- img
- button "Create new branch":
- img - img
- img - img
- heading "Branches" [level=3] - heading "Branches" [level=3]
......
...@@ -21,6 +21,8 @@ import { ...@@ -21,6 +21,8 @@ import {
Edit2, Edit2,
MoreHorizontal, MoreHorizontal,
AlertCircle, AlertCircle,
GitPullRequestArrow,
EllipsisVertical,
} from "lucide-react"; } from "lucide-react";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { useSettings } from "@/hooks/useSettings"; import { useSettings } from "@/hooks/useSettings";
...@@ -38,7 +40,6 @@ import { ...@@ -38,7 +40,6 @@ import {
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { import {
AlertDialog, AlertDialog,
...@@ -98,6 +99,7 @@ export function GithubBranchManager({ ...@@ -98,6 +99,7 @@ export function GithubBranchManager({
const [branchToMerge, setBranchToMerge] = useState<string | null>(null); const [branchToMerge, setBranchToMerge] = useState<string | null>(null);
const [isMerging, setIsMerging] = useState(false); const [isMerging, setIsMerging] = useState(false);
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const [isPulling, setIsPulling] = useState(false);
// State for abort confirmation dialog // State for abort confirmation dialog
const [abortConfirmation, setAbortConfirmation] = useState<{ const [abortConfirmation, setAbortConfirmation] = useState<{
show: boolean; show: boolean;
...@@ -368,6 +370,19 @@ export function GithubBranchManager({ ...@@ -368,6 +370,19 @@ export function GithubBranchManager({
} }
}; };
const handleGitPull = async () => {
setIsPulling(true);
try {
await ipc.github.pull({ appId });
showSuccess("Pulled latest changes from remote");
await loadBranches();
} catch (error: any) {
showError(error.message || "Failed to pull changes");
} finally {
setIsPulling(false);
}
};
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex gap-2"> <div className="flex gap-2">
...@@ -380,7 +395,8 @@ export function GithubBranchManager({ ...@@ -380,7 +395,8 @@ export function GithubBranchManager({
isRenaming || isRenaming ||
isMerging || isMerging ||
isCreating || isCreating ||
isLoading isLoading ||
isPulling
} }
> >
<SelectTrigger className="w-full" data-testid="branch-select-trigger"> <SelectTrigger className="w-full" data-testid="branch-select-trigger">
...@@ -402,45 +418,56 @@ export function GithubBranchManager({ ...@@ -402,45 +418,56 @@ export function GithubBranchManager({
</SelectContent> </SelectContent>
</Select> </Select>
<TooltipProvider> <DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={loadBranches}
disabled={isLoading}
title="Refresh branches"
data-testid="refresh-branches-button"
>
<RefreshCw
className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`}
/>
</Button>
</TooltipTrigger>
<TooltipContent>Refresh branches</TooltipContent>
</Tooltip>
</TooltipProvider>
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<DialogTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
title="Create new branch" title="Branch actions"
data-testid="create-branch-trigger" data-testid="branch-actions-menu-trigger"
> >
<Plus className="h-4 w-4" /> <EllipsisVertical className="h-4 w-4" />
</Button> </Button>
</DialogTrigger> </DropdownMenuTrigger>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Create new branch</TooltipContent> <TooltipContent>Branch actions</TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => setShowCreateDialog(true)}
data-testid="create-branch-trigger"
>
<Plus className="mr-2 h-4 w-4" />
Create new branch
</DropdownMenuItem>
<DropdownMenuItem
onClick={loadBranches}
disabled={isLoading}
data-testid="refresh-branches-button"
>
<RefreshCw
className={`mr-2 h-4 w-4 ${isLoading ? "animate-spin" : ""}`}
/>
Refresh branches
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleGitPull}
disabled={isPulling}
data-testid="git-pull-button"
>
<GitPullRequestArrow
className={`mr-2 h-4 w-4 ${isPulling ? "animate-spin" : ""}`}
/>
Git pull
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Create New Branch</DialogTitle> <DialogTitle>Create New Branch</DialogTitle>
......
...@@ -3,6 +3,7 @@ import { readSettings } from "../../main/settings"; ...@@ -3,6 +3,7 @@ import { readSettings } from "../../main/settings";
import { import {
gitMergeAbort, gitMergeAbort,
gitFetch, gitFetch,
gitPull,
gitCreateBranch, gitCreateBranch,
gitDeleteBranch, gitDeleteBranch,
gitCheckout, gitCheckout,
...@@ -348,10 +349,59 @@ async function handleCommitChanges( ...@@ -348,10 +349,59 @@ async function handleCommitChanges(
}); });
} }
// --- GitHub Pull Handler ---
async function handlePullFromGithub(
event: IpcMainInvokeEvent,
{ appId }: GitBranchAppIdParams,
): Promise<void> {
const settings = readSettings();
const accessToken = settings.githubAccessToken?.value;
if (!accessToken) {
throw new Error("Not authenticated with GitHub.");
}
const app = await db.query.apps.findFirst({ where: eq(apps.id, appId) });
if (!app || !app.githubOrg || !app.githubRepo) {
throw new Error("App is not linked to a GitHub repo.");
}
const appPath = getDyadAppPath(app.path);
const currentBranch = await gitCurrentBranch({ path: appPath });
try {
await gitPull({
path: appPath,
remote: "origin",
branch: currentBranch || "main",
accessToken,
});
} catch (pullError: any) {
// Check if it's a missing remote branch error
const errorMessage = pullError?.message || "";
const isMissingRemoteBranch =
pullError?.code === "MissingRefError" ||
(pullError?.code === "NotFoundError" &&
(errorMessage.includes("remote ref") ||
errorMessage.includes("remote branch"))) ||
errorMessage.includes("couldn't find remote ref") ||
errorMessage.includes("Cannot read properties of null");
// If the remote branch doesn't exist yet, we can ignore this
// (e.g., user hasn't pushed the branch yet)
if (!isMissingRemoteBranch) {
throw pullError;
} else {
logger.debug(
"[GitHub Handler] Remote branch missing during pull, continuing",
errorMessage,
);
}
}
}
// --- Registration --- // --- Registration ---
export function registerGithubBranchHandlers() { export function registerGithubBranchHandlers() {
createTypedHandler(githubContracts.mergeAbort, handleAbortMerge); createTypedHandler(githubContracts.mergeAbort, handleAbortMerge);
createTypedHandler(githubContracts.fetch, handleFetchFromGithub); createTypedHandler(githubContracts.fetch, handleFetchFromGithub);
createTypedHandler(githubContracts.pull, handlePullFromGithub);
createTypedHandler(githubContracts.createBranch, handleCreateBranch); createTypedHandler(githubContracts.createBranch, handleCreateBranch);
createTypedHandler(githubContracts.deleteBranch, handleDeleteBranch); createTypedHandler(githubContracts.deleteBranch, handleDeleteBranch);
createTypedHandler(githubContracts.switchBranch, handleSwitchBranch); createTypedHandler(githubContracts.switchBranch, handleSwitchBranch);
......
...@@ -186,6 +186,12 @@ export const githubContracts = { ...@@ -186,6 +186,12 @@ export const githubContracts = {
output: z.void(), output: z.void(),
}), }),
pull: defineContract({
channel: "github:pull",
input: GitBranchAppIdParamsSchema,
output: z.void(),
}),
rebase: defineContract({ rebase: defineContract({
channel: "github:rebase", channel: "github:rebase",
input: z.object({ appId: z.number() }), input: z.object({ appId: z.number() }),
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论