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

feat: Git collaboration tools with branch management (#2139)

<!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Add Git collaboration with branch management and collaborator controls. Improves GitHub sync with conflict handling, fetch/pull/rebase/merge support, and safer push options. - **New Features** - Branch management: create, switch, rename, delete, and merge branches; list local/remote; detect conflicts; rebase continue/abort and merge abort. - Collaborators: list, invite, and remove collaborators in Publish and App Details when connected. - Sync improvements: fetch/pull (with optional rebase), merge, conflict inspection, branch rename, and reset; push supports force and force-with-lease. - UI updates: branch combobox with refresh/create actions; integrated Branch and Collaborator managers; clearer push success feedback. - **Tests** - New e2e: covers creating, switching, renaming, merging, and deleting branches. - Updated GitHub e2e and snapshots for branch UI and push success message. - Test helper adds “publish” mode; fake GitHub server adds collaborator endpoints and seed data. <sup>Written for commit a9a3166d3742b0fe3e672d69a12531b443b653f9. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. --> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Introduces comprehensive Git collaboration features with UI, IPC, and test coverage. > > - **Branch management UI**: `GithubBranchManager` for create/switch/rename/delete/merge, refresh, source branch selection, conflict banners; integrated into `GitHubConnector` and Publish/App Details panels > - **Collaborator management UI**: `GithubCollaboratorManager` to list/invite/remove collaborators when connected > - **Enhanced sync flow**: `GitHubConnector` supports rebase status, abort/continue, “Rebase and Sync”, safe force push (`force-with-lease`), structured errors, auto-sync deduping > - **New IPC/git ops**: handlers and client methods for `fetch`, `pull`, `rebase` (abort/continue), `merge` (abort), `branch` CRUD, list local/remote branches, conflict listing, git state checks; `git_utils` adds native/isomorphic implementations and conflict/state detection > - **Testing & mocks**: New e2e `git_collaboration.spec.ts` (branch lifecycle, collaborators), updated GitHub specs/snapshots; fake GitHub server adds collaborator endpoints and push event tweaks > - **Plumbing**: Preload exposes new GitHub IPC channels; types extended (`GithubSyncOptions`, git param types) > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 210af660701490139d75b1be86c55e9782056ade. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
上级 59b721b1
import { expect } from "@playwright/test";
import { test, Timeout } from "./helpers/test_helper";
import fs from "fs";
import path from "path";
import { execSync } from "child_process";
test.describe("Git Collaboration", () => {
//create git conflict helper function
test("should create, switch, rename, merge, and delete branches", 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-collab-" + 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,
});
await po.githubConnector.snapshotConnectedRepo();
// 1. Create a new branch
const featureBranch = "feature-1";
// User instruction: Open chat and go to publish tab
await po.goToChatTab();
await po.getTitleBarAppNameButton().click(); // Open Publish Panel
// Wait for BranchManager to appear
await expect(po.page.getByTestId("create-branch-trigger")).toBeVisible({
timeout: 10000,
});
await po.page.getByTestId("create-branch-trigger").click();
await po.page.getByTestId("new-branch-name-input").fill(featureBranch);
await po.page.getByTestId("create-branch-submit-button").click();
// Verify we are on the new branch
//open branches accordion
const branchesCard = po.page.getByTestId("branches-header");
await branchesCard.click();
await expect(
po.page.getByTestId(`branch-item-${featureBranch}`),
).toBeVisible();
// 2. Create a branch from source (create feature-2 from main)
// First switch back to main to ensure we are not on feature-1
await po.page.getByTestId("branch-select-trigger").click();
await po.page.getByRole("option", { name: "main" }).click();
await expect(po.page.getByTestId("current-branch-display")).toHaveText(
"main",
);
const featureBranch2 = "feature-2";
await po.page.getByTestId("create-branch-trigger").click();
await po.page.getByTestId("new-branch-name-input").fill(featureBranch2);
// Select source branch 'main' explicitly (though it defaults to HEAD which is main)
// To test the dropdown, let's select feature-1 as source actually
await po.page.getByTestId("source-branch-select-trigger").click();
await po.page.getByRole("option", { name: featureBranch }).click();
await po.page.getByTestId("create-branch-submit-button").click();
// Verify creation (it auto-switches to the new branch, so we verify we're on it)
await expect(po.page.getByTestId("current-branch-display")).toHaveText(
featureBranch2,
);
{
const appPath = await po.getCurrentAppPath();
if (!appPath) throw new Error("App path not found");
const gitStatus = execSync("git status --porcelain", {
cwd: appPath,
encoding: "utf8",
}).trim();
expect(gitStatus).toBe("");
}
// 3. Rename Branch
// Switch back to main first since we can't rename the branch we're currently on
await po.page.getByTestId("branch-select-trigger").click();
await po.page.getByRole("option", { name: "main" }).click();
await expect(po.page.getByTestId("current-branch-display")).toHaveText(
"main",
);
// Rename feature-2 to feature-2-renamed
const renamedBranch = "feature-2-renamed";
await branchesCard.click();
await po.page.getByTestId(`branch-actions-${featureBranch2}`).click();
await po.page.getByTestId("rename-branch-menu-item").click();
await po.page.getByTestId("rename-branch-input").fill(renamedBranch);
await po.page.getByTestId("rename-branch-submit-button").click();
// Verify rename
await po.page.getByTestId("branch-select-trigger").click();
await expect(
po.page.getByRole("option", { name: renamedBranch }),
).toBeVisible();
await expect(
po.page.getByTestId(`branch-item-${featureBranch2}`),
).not.toBeVisible();
await po.page.keyboard.press("Escape");
// 4. Merge Branch
// First, create a file on feature-1 to verify merge actually works
const appPath = await po.getCurrentAppPath();
if (!appPath) throw new Error("App path not found");
// Switch to feature-1 and create a test file
await po.page.getByTestId("branch-select-trigger").click();
await po.page.getByRole("option", { name: featureBranch }).click();
await expect(po.page.getByTestId("current-branch-display")).toHaveText(
featureBranch,
);
const mergeTestFile = "merge-test.txt";
const mergeTestFilePath = path.join(appPath, mergeTestFile);
const featureContent = "Content from feature-1 branch";
fs.writeFileSync(mergeTestFilePath, featureContent);
// Configure git user for commit
execSync("git config user.email 'test@example.com'", { cwd: appPath });
execSync("git config user.name 'Test User'", { cwd: appPath });
execSync("git config commit.gpgsign false", { cwd: appPath });
execSync(
`git add ${mergeTestFile} && git commit -m "Add merge test file"`,
{
cwd: appPath,
},
);
// Switch back to main
await po.page.getByTestId("branch-select-trigger").click();
await po.page.getByRole("option", { name: "main" }).click();
await expect(po.page.getByTestId("current-branch-display")).toHaveText(
"main",
);
// Verify file doesn't exist on main before merge
expect(fs.existsSync(mergeTestFilePath)).toBe(false);
// Merge feature-1 into main (we are currently on main)
await branchesCard.click();
await po.page.getByTestId(`branch-actions-${featureBranch}`).click();
await po.page.getByTestId("merge-branch-menu-item").click();
await po.page.getByTestId("merge-branch-submit-button").click();
// Wait for merge to complete
await po.waitForToast("success", 10000);
// Verify merge success: file should now exist on main
await expect(async () => {
expect(fs.existsSync(mergeTestFilePath)).toBe(true);
}).toPass({ timeout: 5000 });
expect(fs.readFileSync(mergeTestFilePath, "utf-8")).toBe(featureContent);
// Verify git status is clean (no uncommitted changes)
const gitStatus = execSync("git status --porcelain", {
cwd: appPath,
encoding: "utf8",
}).trim();
expect(gitStatus).toBe("");
// Verify we're still on main branch
const currentBranch = execSync("git branch --show-current", {
cwd: appPath,
encoding: "utf8",
}).trim();
expect(currentBranch).toBe("main");
// 5. Delete Branch
// Delete feature-1
await branchesCard.click();
await po.page.getByTestId(`branch-actions-${featureBranch}`).click();
await po.page.getByTestId("delete-branch-menu-item").click();
await po.page.getByRole("button", { name: "Delete Branch" }).click();
// Verify deletion
await po.page.getByTestId("branch-select-trigger").click();
await expect(
po.page.getByTestId(`branch-item-${featureBranch}`),
).not.toBeVisible();
await po.page.keyboard.press("Escape");
});
test("should invite and remove collaborators", async ({ po }) => {
await po.setUp();
await po.sendPrompt("tc=basic");
await po.selectPreviewMode("publish");
await po.githubConnector.connect();
const repoName = "test-git-collab-invite-" + Date.now();
await po.githubConnector.fillCreateRepoName(repoName);
await po.githubConnector.clickCreateRepoButton();
await expect(po.page.getByTestId("github-connected-repo")).toBeVisible({
timeout: 20000,
});
//open collaborators accordion
const collaboratorsCard = po.page.getByTestId("collaborators-header");
await collaboratorsCard.click();
// Wait for Collaborator Manager
await expect(
po.page.getByTestId("collaborator-invite-input"),
).toBeVisible();
// Invite a fake user
const fakeUser = "test-user-123";
await po.page.getByTestId("collaborator-invite-input").fill(fakeUser);
await po.page.getByTestId("collaborator-invite-button").click();
// Let's check for a toast.
await po.waitForToast("success");
// verify collaborator appears in the list
await expect(
po.page.getByTestId(`collaborator-item-${fakeUser}`),
).toBeVisible();
// Delete collaborator
await po.page.getByTestId(`collaborator-remove-button-${fakeUser}`).click();
await po.page.getByTestId("confirm-remove-collaborator").click();
await po.waitForToast("success");
await expect(
po.page.getByTestId(`collaborator-item-${fakeUser}`),
).not.toBeVisible({ timeout: 5000 });
});
});
......@@ -64,7 +64,7 @@ test("create and sync to new repo - custom branch", async ({ po }) => {
await po.getTitleBarAppNameButton().click();
await po.githubConnector.connect();
await po.githubConnector.fillCreateRepoName("test-new-repo");
await po.githubConnector.fillCreateRepoName("test-new-repo-custom");
await po.githubConnector.fillNewRepoBranchName("new-branch");
// Click create repo button
......@@ -78,7 +78,7 @@ test("create and sync to new repo - custom branch", async ({ po }) => {
// Verify the push was received for the correct custom branch
await po.githubConnector.verifyPushEvent({
repo: "test-new-repo",
repo: "test-new-repo-custom",
branch: "new-branch",
operation: "create",
});
......@@ -91,7 +91,7 @@ test("disconnect from repo", async ({ po }) => {
await po.getTitleBarAppNameButton().click();
await po.githubConnector.connect();
await po.githubConnector.fillCreateRepoName("test-new-repo");
await po.githubConnector.fillCreateRepoName("test-new-repo-disconnect");
await po.githubConnector.clickCreateRepoButton();
await po.githubConnector.clickDisconnectRepoButton();
......
......@@ -582,7 +582,13 @@ export class PageObject {
////////////////////////////////
async selectPreviewMode(
mode: "code" | "problems" | "preview" | "configure" | "security",
mode:
| "code"
| "problems"
| "preview"
| "configure"
| "security"
| "publish",
) {
await this.page.getByTestId(`${mode}-mode-button`).click();
}
......
- paragraph: "Connected to GitHub Repo:"
- text: /testuser\/test-git-collab-\d+/
- combobox:
- img
- text: "Branch: main"
- button "Refresh branches":
- img
- button "Create new branch":
- img
- img
- heading "Branches" [level=3]
- paragraph: Manage your branches, merge, delete, and more.
- img
- button "Sync to GitHub"
- button "Disconnect from repo"
- paragraph: Successfully pushed to GitHub!
\ No newline at end of file
- paragraph: "Connected to GitHub Repo:"
- text: testuser/existing-app
- paragraph: "Branch: new-branch"
- combobox:
- img
- text: "Branch: new-branch"
- button "Refresh branches":
- img
- button "Create new branch":
- img
- img
- heading "Branches" [level=3]
- paragraph: Manage your branches, merge, delete, and more.
- img
- text: main
- button:
- img
- text: new-branch
- button "Sync to GitHub"
- button "Disconnect from repo"
\ No newline at end of file
- button "Disconnect from repo"
- paragraph: Successfully pushed to GitHub!
\ No newline at end of file
- paragraph: "Connected to GitHub Repo:"
- text: testuser/existing-app
- paragraph: "Branch: main"
- combobox:
- img
- text: "Branch: main"
- button "Refresh branches":
- img
- button "Create new branch":
- img
- img
- heading "Branches" [level=3]
- paragraph: Manage your branches, merge, delete, and more.
- img
- button "Sync to GitHub"
- button "Disconnect from repo"
\ No newline at end of file
- button "Disconnect from repo"
- paragraph: Successfully pushed to GitHub!
\ No newline at end of file
- paragraph: "Connected to GitHub Repo:"
- text: testuser/test-new-repo
- paragraph: "Branch: new-branch"
- text: testuser/test-new-repo-custom
- combobox:
- img
- text: "Branch: new-branch"
- button "Refresh branches":
- img
- button "Create new branch":
- img
- img
- heading "Branches" [level=3]
- paragraph: Manage your branches, merge, delete, and more.
- img
- text: main
- button:
- img
- text: new-branch
- button "Sync to GitHub"
- button "Disconnect from repo"
\ No newline at end of file
- button "Disconnect from repo"
- paragraph: Successfully pushed to GitHub!
\ No newline at end of file
- paragraph: "Connected to GitHub Repo:"
- text: testuser/test-new-repo
- paragraph: "Branch: main"
- combobox:
- img
- text: "Branch: main"
- button "Refresh branches":
- img
- button "Create new branch":
- img
- img
- heading "Branches" [level=3]
- paragraph: Manage your branches, merge, delete, and more.
- img
- button "Sync to GitHub"
- button "Disconnect from repo"
\ No newline at end of file
- button "Disconnect from repo"
- paragraph: Successfully pushed to GitHub!
\ No newline at end of file
- paragraph: "Connected to GitHub Repo:"
- text: testuser/test-new-repo
- paragraph: "Branch: main"
- combobox:
- img
- text: "Branch: main"
- button "Refresh branches":
- img
- button "Create new branch":
- img
- img
- heading "Branches" [level=3]
- paragraph: Manage your branches, merge, delete, and more.
- img
- button "Sync to GitHub"
- button "Disconnect from repo"
- paragraph: Successfully pushed to GitHub!
\ No newline at end of file
差异被折叠。
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { SimpleAvatar } from "@/components/ui/SimpleAvatar";
import { IpcClient } from "@/ipc/ipc_client";
import {
Trash2,
UserPlus,
Users,
ChevronsDownUp,
ChevronsUpDown,
} from "lucide-react";
import { showSuccess, showError } from "@/lib/toast";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
interface Collaborator {
login: string;
avatar_url: string;
permissions: {
admin: boolean;
push: boolean;
pull: boolean;
};
}
interface CollaboratorManagerProps {
appId: number;
}
export function GithubCollaboratorManager({ appId }: CollaboratorManagerProps) {
const [collaborators, setCollaborators] = useState<Collaborator[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [inviteUsername, setInviteUsername] = useState("");
const [isInviting, setIsInviting] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [collaboratorToDelete, setCollaboratorToDelete] = useState<
string | null
>(null);
const loadCollaborators = useCallback(async () => {
setIsLoading(true);
try {
const collabs = await IpcClient.getInstance().listCollaborators(appId);
setCollaborators(collabs);
} catch (error: any) {
console.error("Failed to load collaborators:", error);
showError("Failed to load collaborators: " + error.message);
} finally {
setIsLoading(false);
}
}, [appId]);
// Now the effect depends on loadCollaborators, which only changes when appId changes
useEffect(() => {
loadCollaborators();
}, [loadCollaborators]);
const handleInvite = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const trimmedUsername = inviteUsername.trim();
if (!trimmedUsername) return;
setIsInviting(true);
try {
await IpcClient.getInstance().inviteCollaborator(appId, trimmedUsername);
showSuccess(`Invited ${trimmedUsername} to the project.`);
setInviteUsername("");
// Reload list (though they might be pending)
loadCollaborators();
} catch (error: any) {
showError(error.message);
} finally {
setIsInviting(false);
}
};
const handleRemove = async () => {
if (!collaboratorToDelete) return;
try {
await IpcClient.getInstance().removeCollaborator(
appId,
collaboratorToDelete,
);
showSuccess(`Removed ${collaboratorToDelete} from the project.`);
loadCollaborators();
} catch (error: any) {
showError(error.message);
} finally {
setCollaboratorToDelete(null);
}
};
return (
<Card className="transition-all duration-200">
<CardHeader
className="p-2 cursor-pointer"
onClick={() => setIsExpanded((prev) => !prev)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Users className="w-5 h-5" />
<div>
<CardTitle className="text-sm" data-testid="collaborators-header">
Collaborators
</CardTitle>
<CardDescription className="text-xs">
Manage who has access to this project via GitHub.
</CardDescription>
</div>
</div>
{isExpanded ? (
<ChevronsDownUp className="w-5 h-5 text-gray-500" />
) : (
<ChevronsUpDown className="w-5 h-5 text-gray-500" />
)}
</div>
</CardHeader>
<div
className={`overflow-hidden transition-[max-height,opacity] duration-200 ease-in-out ${
isExpanded ? "max-h-[2000px] opacity-100" : "max-h-0 opacity-0"
}`}
>
<CardContent className="space-y-4">
{/* Invite Form */}
<form onSubmit={handleInvite} className="flex gap-2">
<Input
placeholder="GitHub username"
value={inviteUsername}
onChange={(e) => setInviteUsername(e.target.value)}
disabled={isInviting}
data-testid="collaborator-invite-input"
/>
<Button
type="submit"
data-testid="collaborator-invite-button"
disabled={isInviting || !inviteUsername.trim()}
>
{isInviting ? (
"Inviting..."
) : (
<>
<UserPlus className="w-4 h-4 mr-2" />
Invite
</>
)}
</Button>
</form>
{/* Collaborators List */}
<div className="space-y-2 mt-4">
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400">
Current Team
</h3>
{isLoading ? (
<div className="text-sm text-center py-4 text-gray-500">
Loading collaborators...
</div>
) : collaborators.length === 0 ? (
<div className="text-sm text-center py-4 text-gray-500 bg-gray-50 dark:bg-gray-800/50 rounded-md">
No collaborators found.
</div>
) : (
<div className="space-y-2">
{collaborators.map((collab) => (
<div
key={collab.login}
data-testid={`collaborator-item-${collab.login}`}
className="flex items-center justify-between p-2 rounded-md border border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-900"
>
<div className="flex items-center gap-3">
<SimpleAvatar
src={collab.avatar_url}
alt={collab.login}
fallbackText={collab.login.slice(0, 2).toUpperCase()}
/>
<div>
<p className="text-sm font-medium">{collab.login}</p>
<p className="text-xs text-gray-500">
{collab.permissions.admin
? "Admin"
: collab.permissions.push
? "Editor"
: "Viewer"}
</p>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
data-testid={`collaborator-remove-button-${collab.login}`}
onClick={() => setCollaboratorToDelete(collab.login)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
))}
</div>
)}
</div>
</CardContent>
</div>
<AlertDialog
open={!!collaboratorToDelete}
onOpenChange={(open) => {
if (!open) setCollaboratorToDelete(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove collaborator?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to remove{" "}
<span className="font-medium">{collaboratorToDelete}</span> from
this project? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel data-testid="confirm-remove-collaborator-cancel">
Cancel
</AlertDialogCancel>
<AlertDialogAction
data-testid="confirm-remove-collaborator"
onClick={handleRemove}
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Card>
);
}
......@@ -6,6 +6,7 @@ import { VercelConnector } from "@/components/VercelConnector";
import { PortalMigrate } from "@/components/PortalMigrate";
import { IpcClient } from "@/ipc/ipc_client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { GithubCollaboratorManager } from "@/components/GithubCollaboratorManager";
export const PublishPanel = () => {
const selectedAppId = useAtomValue(selectedAppIdAtom);
......@@ -105,6 +106,11 @@ export const PublishPanel = () => {
folderName={app.name}
expanded={true}
/>
{app.githubOrg && app.githubRepo && (
<div className="pt-4 border-t border-gray-100 dark:border-gray-800">
<GithubCollaboratorManager appId={selectedAppId} />
</div>
)}
</CardContent>
</Card>
......
import { useState, useEffect } from "react";
interface SimpleAvatarProps {
src?: string;
alt?: string;
fallbackText?: string;
}
export function SimpleAvatar({ src, alt, fallbackText }: SimpleAvatarProps) {
const [hasError, setHasError] = useState(false);
// Reset error state when src changes so new images can be attempted
useEffect(() => {
setHasError(false);
}, [src]);
const showImage = src && !hasError;
return (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-200 dark:bg-gray-800 overflow-hidden text-xs font-medium">
{showImage ? (
<img
src={src}
alt={alt}
className="h-full w-full object-cover"
onError={() => setHasError(true)}
/>
) : (
<span>{fallbackText}</span>
)}
</div>
);
}
......@@ -44,6 +44,7 @@ export interface GitPushParams extends GitBaseParams {
branch: string;
accessToken: string;
force?: boolean;
forceWithLease?: boolean;
}
export interface GitFileAtCommitParams extends GitBaseParams {
filePath: string;
......@@ -58,3 +59,36 @@ export interface GitInitParams extends GitBaseParams {
export interface GitStageToRevertParams extends GitBaseParams {
targetOid: string;
}
export interface GitAuthorParam {
name: string;
email: string;
timestamp?: number;
timezoneOffset?: number;
}
export interface GitFetchParams extends GitBaseParams {
remote?: string;
accessToken?: string;
}
export interface GitPullParams extends GitBaseParams {
remote?: string;
branch?: string;
accessToken?: string;
author?: GitAuthorParam;
rebase?: boolean;
}
export interface GitMergeParams extends GitBaseParams {
branch: string;
author?: GitAuthorParam;
}
export interface GitCreateBranchParams extends GitBaseParams {
branch: string;
from?: string;
}
export interface GitDeleteBranchParams extends GitBaseParams {
branch: string;
}
import { ipcMain, IpcMainInvokeEvent } from "electron";
import { readSettings } from "../../main/settings";
import {
gitMergeAbort,
gitFetch,
gitCreateBranch,
gitDeleteBranch,
gitCheckout,
gitMerge,
gitCurrentBranch,
gitListBranches,
gitListRemoteBranches,
gitRenameBranch,
GitStateError,
GIT_ERROR_CODES,
isGitMergeInProgress,
isGitRebaseInProgress,
} from "../utils/git_utils";
import { getDyadAppPath } from "../../paths/paths";
import { db } from "../../db";
import { apps } from "../../db/schema";
import { eq } from "drizzle-orm";
import log from "electron-log";
import { withLock } from "../utils/lock_utils";
import { updateAppGithubRepo, ensureCleanWorkspace } from "./github_handlers";
const logger = log.scope("git_branch_handlers");
async function handleAbortMerge(
event: IpcMainInvokeEvent,
{ appId }: { appId: number },
): Promise<void> {
const app = await db.query.apps.findFirst({ where: eq(apps.id, appId) });
if (!app) throw new Error("App not found");
const appPath = getDyadAppPath(app.path);
await gitMergeAbort({ path: appPath });
}
// --- GitHub Fetch Handler ---
async function handleFetchFromGithub(
event: IpcMainInvokeEvent,
{ appId }: { appId: number },
): 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);
await gitFetch({
path: appPath,
remote: "origin",
accessToken,
});
}
// --- GitHub Branch Handlers ---
async function handleCreateBranch(
event: IpcMainInvokeEvent,
{ appId, branch, from }: { appId: number; branch: string; from?: string },
): Promise<void> {
// Validate branch name
if (!branch || branch.length === 0 || branch.length > 255) {
throw new Error("Branch name must be between 1 and 255 characters");
}
if (!/^[a-zA-Z0-9/_.-]+$/.test(branch) || /\.\./.test(branch)) {
throw new Error("Branch name contains invalid characters");
}
if (
branch.startsWith("-") ||
branch === "HEAD" ||
branch.endsWith(".") ||
branch.endsWith(".lock") ||
branch.startsWith("/") ||
branch.endsWith("/") ||
branch.includes("@{")
) {
throw new Error("Invalid branch name");
}
const app = await db.query.apps.findFirst({ where: eq(apps.id, appId) });
if (!app) throw new Error("App not found");
const appPath = getDyadAppPath(app.path);
await gitCreateBranch({
path: appPath,
branch,
from,
});
}
async function handleDeleteBranch(
event: IpcMainInvokeEvent,
{ appId, branch }: { appId: number; branch: string },
): Promise<void> {
const app = await db.query.apps.findFirst({ where: eq(apps.id, appId) });
if (!app) throw new Error("App not found");
const appPath = getDyadAppPath(app.path);
await gitDeleteBranch({
path: appPath,
branch,
});
}
async function handleSwitchBranch(
event: IpcMainInvokeEvent,
{ appId, branch }: { appId: number; branch: string },
): Promise<void> {
const app = await db.query.apps.findFirst({ where: eq(apps.id, appId) });
if (!app) throw new Error("App not found");
const appPath = getDyadAppPath(app.path);
// Check for merge or rebase in progress before attempting to switch
// This provides structured error codes instead of relying on string matching
if (isGitMergeInProgress({ path: appPath })) {
throw GitStateError(
"Cannot switch branches: merge in progress. Please complete or abort the merge first.",
GIT_ERROR_CODES.MERGE_IN_PROGRESS,
);
}
if (isGitRebaseInProgress({ path: appPath })) {
throw GitStateError(
"Cannot switch branches: rebase in progress. Please complete or abort the rebase first.",
GIT_ERROR_CODES.REBASE_IN_PROGRESS,
);
}
// Check for uncommitted changes
await withLock(appId, async () => {
await ensureCleanWorkspace(appPath, `switching to branch '${branch}'`);
});
try {
await gitCheckout({
path: appPath,
ref: branch,
});
} catch (checkoutError: any) {
const errorMessage = checkoutError?.message || "Failed to switch branch.";
// Check if error is about uncommitted changes (fallback in case check above missed it)
const lowerMessage = errorMessage.toLowerCase();
if (
lowerMessage.includes("local changes") ||
lowerMessage.includes("would be overwritten") ||
lowerMessage.includes("please commit or stash")
) {
throw new Error(
`Failed to switch branch: uncommitted changes detected. ` +
"Please commit or stash your changes manually and try again.",
);
}
throw checkoutError;
}
// Update DB with new branch
await updateAppGithubRepo({
appId,
org: app.githubOrg || undefined,
repo: app.githubRepo || "",
branch,
});
}
async function handleRenameBranch(
event: IpcMainInvokeEvent,
{
appId,
oldBranch,
newBranch,
}: { appId: number; oldBranch: string; newBranch: string },
): Promise<void> {
const app = await db.query.apps.findFirst({ where: eq(apps.id, appId) });
if (!app) throw new Error("App not found");
const appPath = getDyadAppPath(app.path);
// Check if we're renaming the current branch BEFORE renaming to avoid race conditions
const currentBranch = await gitCurrentBranch({ path: appPath });
const isRenamingCurrentBranch = currentBranch === oldBranch;
await gitRenameBranch({
path: appPath,
oldBranch,
newBranch,
});
// Only update DB if we were on oldBranch before renaming
// (git branch -m renames the current branch if we're on it, so HEAD now points to newBranch)
if (isRenamingCurrentBranch) {
await updateAppGithubRepo({
appId,
org: app.githubOrg || undefined,
repo: app.githubRepo || "",
branch: newBranch,
});
}
}
// Custom error class for merge conflicts
class MergeConflictError extends Error {
constructor(message: string) {
super(message);
this.name = "MergeConflictError";
}
}
async function handleMergeBranch(
event: IpcMainInvokeEvent,
{ appId, branch }: { appId: number; branch: string },
): Promise<void> {
const app = await db.query.apps.findFirst({ where: eq(apps.id, appId) });
if (!app) throw new Error("App not found");
const appPath = getDyadAppPath(app.path);
// Check if branch exists locally, if not, check if it's a remote branch
const localBranches = await gitListBranches({ path: appPath });
let remoteBranches: string[] = [];
try {
remoteBranches = await gitListRemoteBranches({
path: appPath,
});
} catch (error: any) {
logger.warn(`Failed to list remote branches: ${error.message}`);
// Continue with empty remote branches list
}
let mergeBranchRef = branch;
// If branch doesn't exist locally but exists remotely, use remote ref
if (!localBranches.includes(branch) && remoteBranches.includes(branch)) {
mergeBranchRef = `origin/${branch}`;
}
// Check for uncommitted changes
await withLock(appId, async () => {
await ensureCleanWorkspace(appPath, `merging branch '${branch}'`);
});
try {
await gitMerge({
path: appPath,
branch: mergeBranchRef,
});
} catch (mergeError: any) {
// Convert to MergeConflictError for component compatibility
if (mergeError?.name === "GitConflictError") {
throw new MergeConflictError(mergeError.message);
}
// Fallback: Check if error is about uncommitted changes
const errorMessage = mergeError?.message || "Failed to merge branch.";
const lowerMessage = errorMessage.toLowerCase();
if (
lowerMessage.includes("local changes") ||
lowerMessage.includes("would be overwritten") ||
lowerMessage.includes("please commit or stash")
) {
throw new Error(
`Failed to merge branch: uncommitted changes detected. ` +
"Please commit or stash your changes manually and try again.",
);
}
// Otherwise, throw the original error
throw mergeError;
}
}
async function handleListLocalBranches(
event: IpcMainInvokeEvent,
{ appId }: { appId: number },
): Promise<{ branches: string[]; current: string | null }> {
const app = await db.query.apps.findFirst({ where: eq(apps.id, appId) });
if (!app) throw new Error("App not found");
const appPath = getDyadAppPath(app.path);
const branches = await gitListBranches({ path: appPath });
const current = await gitCurrentBranch({ path: appPath });
return { branches, current: current || null };
}
async function handleListRemoteBranches(
event: IpcMainInvokeEvent,
{ appId, remote = "origin" }: { appId: number; remote?: string },
): Promise<string[]> {
const app = await db.query.apps.findFirst({ where: eq(apps.id, appId) });
if (!app) throw new Error("App not found");
const appPath = getDyadAppPath(app.path);
const branches = await gitListRemoteBranches({ path: appPath, remote });
return branches;
}
// --- Registration ---
export function registerGithubBranchHandlers() {
ipcMain.handle("github:merge-abort", handleAbortMerge);
ipcMain.handle("github:fetch", handleFetchFromGithub);
ipcMain.handle("github:create-branch", handleCreateBranch);
ipcMain.handle("github:delete-branch", handleDeleteBranch);
ipcMain.handle("github:switch-branch", handleSwitchBranch);
ipcMain.handle("github:rename-branch", handleRenameBranch);
ipcMain.handle("github:merge-branch", handleMergeBranch);
ipcMain.handle("github:list-local-branches", handleListLocalBranches);
ipcMain.handle("github:list-remote-branches", handleListRemoteBranches);
}
......@@ -84,6 +84,7 @@ import type {
AgentToolConsentResponseParams,
AgentTodosUpdatePayload,
TelemetryEventPayload,
GithubSyncOptions,
ConsoleEntry,
} from "./ipc_types";
import type { Template } from "../shared/templates";
......@@ -861,19 +862,143 @@ export class IpcClient {
// Sync (push) local repo to GitHub
public async syncGithubRepo(
appId: number,
force?: boolean,
): Promise<{ success: boolean; error?: string }> {
return this.ipcRenderer.invoke("github:push", {
options: GithubSyncOptions = {},
): Promise<void> {
const { force, forceWithLease } = options;
await this.ipcRenderer.invoke("github:push", {
appId,
force,
forceWithLease,
});
}
public async abortGithubRebase(appId: number): Promise<void> {
await this.ipcRenderer.invoke("github:rebase-abort", {
appId,
});
}
public async abortGithubMerge(appId: number): Promise<void> {
await this.ipcRenderer.invoke("github:merge-abort", {
appId,
});
}
public async continueGithubRebase(appId: number): Promise<void> {
await this.ipcRenderer.invoke("github:rebase-continue", {
appId,
});
}
public async rebaseGithubRepo(appId: number): Promise<void> {
await this.ipcRenderer.invoke("github:rebase", { appId });
}
public async disconnectGithubRepo(appId: number): Promise<void> {
await this.ipcRenderer.invoke("github:disconnect", {
appId,
});
}
public async fetchGithubRepo(appId: number): Promise<void> {
await this.ipcRenderer.invoke("github:fetch", { appId });
}
public async createGithubBranch(
appId: number,
branch: string,
from?: string,
): Promise<void> {
await this.ipcRenderer.invoke("github:create-branch", {
appId,
branch,
from,
});
}
public async deleteGithubBranch(
appId: number,
branch: string,
): Promise<void> {
await this.ipcRenderer.invoke("github:delete-branch", { appId, branch });
}
public async switchGithubBranch(
appId: number,
branch: string,
): Promise<void> {
await this.ipcRenderer.invoke("github:switch-branch", { appId, branch });
}
public async renameGithubBranch(
appId: number,
oldBranch: string,
newBranch: string,
): Promise<void> {
await this.ipcRenderer.invoke("github:rename-branch", {
appId,
oldBranch,
newBranch,
});
}
public async mergeGithubBranch(appId: number, branch: string): Promise<void> {
await this.ipcRenderer.invoke("github:merge-branch", { appId, branch });
}
public async getGithubMergeConflicts(appId: number): Promise<string[]> {
return this.ipcRenderer.invoke("github:get-conflicts", { appId });
}
public async listLocalGithubBranches(
appId: number,
): Promise<{ branches: string[]; current: string | null }> {
return this.ipcRenderer.invoke("github:list-local-branches", { appId });
}
public async listRemoteGithubBranches(
appId: number,
remote = "origin",
): Promise<string[]> {
return this.ipcRenderer.invoke("github:list-remote-branches", {
appId,
remote,
});
}
public async getGithubState(appId: number): Promise<{
mergeInProgress: boolean;
rebaseInProgress: boolean;
}> {
return this.ipcRenderer.invoke("github:get-git-state", { appId });
}
public async listCollaborators(
appId: number,
): Promise<{ login: string; avatar_url: string; permissions: any }[]> {
return this.ipcRenderer.invoke("github:list-collaborators", { appId });
}
public async inviteCollaborator(
appId: number,
username: string,
): Promise<void> {
await this.ipcRenderer.invoke("github:invite-collaborator", {
appId,
username,
});
}
public async removeCollaborator(
appId: number,
username: string,
): Promise<void> {
await this.ipcRenderer.invoke("github:remove-collaborator", {
appId,
username,
});
}
// --- End GitHub Repo Management ---
// --- Vercel Token Management ---
......
......@@ -5,6 +5,7 @@ import { registerSettingsHandlers } from "./handlers/settings_handlers";
import { registerShellHandlers } from "./handlers/shell_handler";
import { registerDependencyHandlers } from "./handlers/dependency_handlers";
import { registerGithubHandlers } from "./handlers/github_handlers";
import { registerGithubBranchHandlers } from "./handlers/git_branch_handlers";
import { registerVercelHandlers } from "./handlers/vercel_handlers";
import { registerNodeHandlers } from "./handlers/node_handlers";
import { registerProposalHandlers } from "./handlers/proposal_handlers";
......@@ -44,6 +45,7 @@ export function registerIpcHandlers() {
registerShellHandlers();
registerDependencyHandlers();
registerGithubHandlers();
registerGithubBranchHandlers();
registerVercelHandlers();
registerNodeHandlers();
registerProblemsHandlers();
......
......@@ -562,6 +562,12 @@ export interface GithubRepository {
private: boolean;
}
export interface GithubSyncOptions {
force?: boolean;
rebase?: boolean;
forceWithLease?: boolean;
}
export type CloneRepoReturnType =
| {
app: App;
......
差异被折叠。
......@@ -38,6 +38,7 @@ import { useDebounce } from "@/hooks/useDebounce";
import { useCheckName } from "@/hooks/useCheckName";
import { AppUpgrades } from "@/components/AppUpgrades";
import { CapacitorControls } from "@/components/CapacitorControls";
import { GithubCollaboratorManager } from "@/components/GithubCollaboratorManager";
export default function AppDetailsPage() {
const navigate = useNavigate();
......@@ -391,6 +392,11 @@ export default function AppDetailsPage() {
</Button>
<div className="border border-gray-200 rounded-md p-4">
<GitHubConnector appId={appId} folderName={selectedApp.path} />
{selectedApp.githubOrg && selectedApp.githubRepo && appId && (
<div className="pt-4 border-t border-gray-100 dark:border-gray-800">
<GithubCollaboratorManager appId={appId} />
</div>
)}
</div>
{appId && <SupabaseConnector appId={appId} />}
{appId && <CapacitorControls appId={appId} />}
......
......@@ -62,6 +62,23 @@ const validInvokeChannels = [
"github:create-repo",
"github:connect-existing-repo",
"github:push",
"github:fetch",
"github:rebase",
"github:rebase-abort",
"github:merge-abort",
"github:rebase-continue",
"github:list-local-branches",
"github:list-remote-branches",
"github:create-branch",
"github:switch-branch",
"github:list-collaborators",
"github:invite-collaborator",
"github:remove-collaborator",
"github:rename-branch",
"github:merge-branch",
"github:get-conflicts",
"github:delete-branch",
"github:get-git-state",
"github:disconnect",
"neon:create-project",
"neon:get-project",
......
......@@ -59,6 +59,12 @@ const mockBranches = [
{ name: "feature/test", commit: { sha: "ghi789" } },
];
// Simple in-memory collaborator store keyed by full repo name
const repoCollaborators: Record<
string,
{ login: string; avatar_url: string; permissions: any }[]
> = {};
// Store device flow state
let deviceFlowState = {
deviceCode: mockDeviceCode,
......@@ -202,6 +208,15 @@ export function handleUserRepos(req: Request, res: Response) {
default_branch: "main",
};
mockRepos.push(newRepo);
repoCollaborators[newRepo.full_name] = [
{
login: mockUser.login,
avatar_url: "https://example.com/avatar.png",
permissions: { admin: true, push: true, pull: true },
},
];
res.status(201).json(newRepo);
}
}
......@@ -269,6 +284,56 @@ export function handleOrgRepos(req: Request, res: Response) {
handleUserRepos(req, res);
}
export function handleRepoCollaborators(req: Request, res: Response) {
console.log("* GitHub Repo collaborators requested");
const { owner, repo } = req.params;
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.includes(mockAccessToken)) {
return res.status(401).json({
message: "Bad credentials",
});
}
const repoName = `${owner}/${repo}`;
const foundRepo = mockRepos.find((r) => r.full_name === repoName);
if (!foundRepo) {
return res.status(404).json({
message: "Not Found",
});
}
if (req.method === "GET") {
return res.json(repoCollaborators[repoName] || []);
}
if (req.method === "PUT") {
const username = req.params.username;
const collaborators = repoCollaborators[repoName] || [];
const existing = collaborators.find((c) => c.login === username);
if (!existing) {
collaborators.push({
login: username,
avatar_url: `https://example.com/avatars/${username}.png`,
permissions: { pull: true, push: true, admin: false },
});
}
repoCollaborators[repoName] = collaborators;
return res.status(201).json({ invitation: true });
}
if (req.method === "DELETE") {
const username = req.params.username;
repoCollaborators[repoName] = (repoCollaborators[repoName] || []).filter(
(c) => c.login !== username,
);
return res.status(204).send();
}
return res.status(405).json({ message: "Method not allowed" });
}
// Push event management functions for testing
export function handleGetPushEvents(req: Request, res: Response) {
console.log("* Getting push events");
......
......@@ -15,6 +15,7 @@ import {
handleGitPush,
handleGetPushEvents,
handleClearPushEvents,
handleRepoCollaborators,
} from "./githubHandler";
// Create Express app
......@@ -182,6 +183,18 @@ app.get("/github/api/user/repos", handleUserRepos);
app.post("/github/api/user/repos", handleUserRepos);
app.get("/github/api/repos/:owner/:repo", handleRepo);
app.get("/github/api/repos/:owner/:repo/branches", handleRepoBranches);
app.get(
"/github/api/repos/:owner/:repo/collaborators",
handleRepoCollaborators,
);
app.put(
"/github/api/repos/:owner/:repo/collaborators/:username",
handleRepoCollaborators,
);
app.delete(
"/github/api/repos/:owner/:repo/collaborators/:username",
handleRepoCollaborators,
);
app.post("/github/api/orgs/:org/repos", handleOrgRepos);
// GitHub test endpoints for verifying push operations
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论