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
......@@ -6,6 +6,7 @@ import {
Check,
AlertTriangle,
ChevronRight,
GitMerge,
} from "lucide-react";
import { IpcClient } from "@/ipc/ipc_client";
import { useSettings } from "@/hooks/useSettings";
......@@ -27,6 +28,12 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { GithubBranchManager } from "@/components/GithubBranchManager";
import type { GithubSyncOptions } from "@/ipc/ipc_types";
type SyncResult =
| { error: Error; handled?: boolean }
| { error?: undefined; handled?: boolean };
interface GitHubConnectorProps {
appId: number | null;
......@@ -75,7 +82,15 @@ function ConnectedGitHubConnector({
const [showForceDialog, setShowForceDialog] = useState(false);
const [isDisconnecting, setIsDisconnecting] = useState(false);
const [disconnectError, setDisconnectError] = useState<string | null>(null);
const autoSyncTriggeredRef = useRef(false);
const [conflicts, setConflicts] = useState<string[]>([]);
const [rebaseStatusMessage, setRebaseStatusMessage] = useState<string | null>(
null,
);
const [rebaseAction, setRebaseAction] = useState<
"abort" | "continue" | "safe-push" | null
>(null);
const [rebaseInProgress, setRebaseInProgress] = useState(false);
const lastAutoSyncedAppIdRef = useRef<number | null>(null);
const handleDisconnectRepo = async () => {
setIsDisconnecting(true);
......@@ -91,31 +106,79 @@ function ConnectedGitHubConnector({
};
const handleSyncToGithub = useCallback(
async (force: boolean = false) => {
async ({
force = false,
forceWithLease = false,
}: GithubSyncOptions = {}): Promise<SyncResult> => {
setIsSyncing(true);
setSyncError(null);
setSyncSuccess(false);
setShowForceDialog(false);
setRebaseInProgress(false);
setConflicts([]); // Clear conflicts when starting a new sync
try {
const result = await IpcClient.getInstance().syncGithubRepo(
appId,
await IpcClient.getInstance().syncGithubRepo(appId, {
force,
);
if (result.success) {
setSyncSuccess(true);
} else {
setSyncError(result.error || "Failed to sync to GitHub.");
// If it's a push rejection error, show the force dialog
if (
result.error?.includes("rejected") ||
result.error?.includes("non-fast-forward")
) {
// Don't show force dialog immediately, let user see the error first
forceWithLease,
});
setSyncSuccess(true);
setRebaseInProgress(false);
setConflicts([]); // Clear conflicts on successful sync
setRebaseStatusMessage(null);
return {};
} catch (err: any) {
if (err?.name === "GitConflictError") {
try {
const mergeConflicts =
await IpcClient.getInstance().getGithubMergeConflicts(appId);
if (mergeConflicts.length > 0) {
setConflicts(mergeConflicts);
setSyncError(
"Merge conflicts detected. Please resolve them in the editor.",
);
(err as Error & { handled?: boolean }).handled = true;
return { error: err, handled: true };
}
} catch {
// If getGithubMergeConflicts fails, fall through to handle the original GitConflictError
// The error from getGithubMergeConflicts is intentionally not handled here
// so the original GitConflictError can be displayed to the user
}
}
} catch (err: any) {
setSyncError(err.message || "Failed to sync to GitHub.");
// Check for structured error codes instead of parsing error messages
const errorCode = err?.code as
| "REBASE_IN_PROGRESS"
| "MERGE_IN_PROGRESS"
| undefined;
// Fallback: query backend git state if structured error code is missing
let inferredRebaseInProgress = false;
if (!errorCode) {
try {
const state = await IpcClient.getInstance().getGithubState(appId);
inferredRebaseInProgress = state.rebaseInProgress;
} catch {
// ignore state inference errors
}
}
// Final fallback: inspect error message for known rebase markers when state fetch fails
const messageIndicatesRebase =
typeof err?.message === "string" &&
err.message.toLowerCase().includes("rebase-merge");
const rebaseInProgressState =
errorCode === "REBASE_IN_PROGRESS" ||
inferredRebaseInProgress ||
messageIndicatesRebase;
const errorMessage = err.message || "Failed to sync to GitHub.";
setSyncError(errorMessage);
setRebaseInProgress(rebaseInProgressState);
setRebaseStatusMessage(null);
return { error: err };
} finally {
setIsSyncing(false);
}
......@@ -123,18 +186,130 @@ function ConnectedGitHubConnector({
[appId],
);
const handleAbortRebase = useCallback(async () => {
setRebaseAction("abort");
setSyncError(null);
setRebaseStatusMessage(null);
setSyncSuccess(false);
try {
await IpcClient.getInstance().abortGithubRebase(appId);
setRebaseInProgress(false);
setRebaseStatusMessage("Rebase aborted. You can try syncing again.");
} catch (err: any) {
setSyncError(err.message || "Failed to abort rebase.");
setRebaseInProgress(true);
} finally {
setRebaseAction(null);
}
}, [appId]);
const handleContinueRebase = useCallback(async () => {
setRebaseAction("continue");
setSyncError(null);
setRebaseStatusMessage(null);
setSyncSuccess(false);
try {
await IpcClient.getInstance().continueGithubRebase(appId);
setRebaseInProgress(false);
setRebaseStatusMessage("Rebase continued. You can sync when ready.");
} catch (err: any) {
setSyncError(err.message || "Failed to continue rebase.");
setRebaseInProgress(true);
} finally {
setRebaseAction(null);
}
}, [appId]);
const handleSafeForcePush = useCallback(async () => {
setRebaseAction("safe-push");
try {
await handleSyncToGithub({
force: false,
forceWithLease: true,
});
} finally {
setRebaseAction(null);
}
}, [handleSyncToGithub]);
const handleRebaseAndSync = useCallback(async () => {
setIsSyncing(true);
try {
// First, perform the rebase
await IpcClient.getInstance().rebaseGithubRepo(appId);
setRebaseStatusMessage(null);
const syncResult = await handleSyncToGithub();
if (syncResult?.error) {
if (!syncResult.handled) {
throw syncResult.error;
}
return;
}
setRebaseStatusMessage("Rebase and push completed successfully.");
} catch (err: any) {
if (err?.handled) {
return;
}
const errorMessage =
err?.message || "Failed to rebase and sync to GitHub.";
setSyncError(errorMessage);
setRebaseInProgress(errorMessage.includes("rebase-merge"));
// If rebase failed, show appropriate message
if (errorMessage.includes("rebase")) {
setRebaseStatusMessage(
"Rebase failed. You may need to resolve conflicts or abort the rebase.",
);
}
// Clear any stale rebase success message if sync failed after rebase
if (errorMessage.includes("sync") || errorMessage.includes("push")) {
setRebaseStatusMessage(null);
}
} finally {
// Ensure syncing state is reset whether rebase or sync fails before handleSyncToGithub runs its own cleanup
setIsSyncing(false);
}
}, [appId, handleSyncToGithub]);
// Auto-sync when triggerAutoSync prop is true
useEffect(() => {
if (triggerAutoSync && !autoSyncTriggeredRef.current) {
autoSyncTriggeredRef.current = true;
handleSyncToGithub(false).finally(() => {
onAutoSyncComplete?.();
});
} else if (!triggerAutoSync) {
// Reset the ref when triggerAutoSync becomes false
autoSyncTriggeredRef.current = false;
if (!appId) return;
// Only auto-sync once per appId
const alreadySyncedForThisApp = lastAutoSyncedAppIdRef.current === appId;
if (triggerAutoSync && !alreadySyncedForThisApp && !isSyncing) {
lastAutoSyncedAppIdRef.current = appId;
handleSyncToGithub()
.catch(() => {
// Error is already handled in handleSyncToGithub via state updates
})
.finally(() => {
onAutoSyncComplete?.();
});
}
// allow re-sync if triggerAutoSync is explicitly turned off
if (
!triggerAutoSync &&
!isSyncing &&
lastAutoSyncedAppIdRef.current === appId
) {
lastAutoSyncedAppIdRef.current = null;
}
}, [triggerAutoSync]); // Only depend on triggerAutoSync to avoid unnecessary re-runs
}, [
appId,
triggerAutoSync,
isSyncing,
handleSyncToGithub,
onAutoSyncComplete,
]);
const isForcePushError =
syncError?.includes("rejected") || syncError?.includes("non-fast-forward");
const showRebaseAndSync = syncError?.includes("divergent branches");
const showRebaseRecoveryOptions =
rebaseInProgress || (syncError?.includes("rebase-merge") ?? false);
const isRebaseActionPending = isSyncing || !!rebaseAction;
return (
<div className="w-full" data-testid="github-connected-repo">
......@@ -153,12 +328,13 @@ function ConnectedGitHubConnector({
{app.githubOrg}/{app.githubRepo}
</a>
{app.githubBranch && (
<p className="text-sm text-gray-600 dark:text-gray-300 mt-1">
Branch: <span className="font-mono">{app.githubBranch}</span>
</p>
<GithubBranchManager appId={appId} onBranchChange={refreshApp} />
)}
<div className="mt-2 flex gap-2">
<Button onClick={() => handleSyncToGithub(false)} disabled={isSyncing}>
<Button
onClick={() => handleSyncToGithub()}
disabled={isRebaseActionPending}
>
{isSyncing ? (
<>
<svg
......@@ -197,7 +373,7 @@ function ConnectedGitHubConnector({
</Button>
</div>
{syncError && (
<div className="mt-2">
<div className="mt-2 space-y-2">
<p className="text-red-600">
{syncError}{" "}
<a
......@@ -214,20 +390,86 @@ function ConnectedGitHubConnector({
See troubleshooting guide
</a>
</p>
{(syncError.includes("rejected") ||
syncError.includes("non-fast-forward")) && (
{showRebaseRecoveryOptions && (
<div className="space-y-2 rounded-md border border-orange-200 p-3 dark:border-orange-800 dark:bg-orange-900/20">
<p className="text-sm text-orange-800 dark:text-orange-100">
A rebase is already in progress. Choose how to proceed.
</p>
<div className="flex flex-wrap gap-2">
<Button
onClick={handleAbortRebase}
variant="outline"
size="sm"
disabled={isRebaseActionPending}
>
<AlertTriangle className="h-4 w-4 mr-2" />
{rebaseAction === "abort" ? "Aborting..." : "Abort rebase"}
</Button>
<Button
onClick={handleContinueRebase}
variant="outline"
size="sm"
disabled={isRebaseActionPending}
>
<GitMerge className="h-4 w-4 mr-2" />
{rebaseAction === "continue"
? "Continuing..."
: "Continue rebase"}
</Button>
<Button
onClick={handleSafeForcePush}
variant="outline"
size="sm"
disabled={isRebaseActionPending}
className="text-orange-600 border-orange-600 hover:bg-orange-50"
>
<AlertTriangle className="h-4 w-4 mr-2" />
{rebaseAction === "safe-push"
? "Safe force pushing..."
: "Safe Force Push"}
</Button>
</div>
</div>
)}
{isForcePushError && (
<Button
onClick={() => setShowForceDialog(true)}
variant="outline"
size="sm"
className="mt-2 text-orange-600 border-orange-600 hover:bg-orange-50"
disabled={isRebaseActionPending}
className="text-orange-600 border-orange-600 hover:bg-orange-50"
>
<AlertTriangle className="h-4 w-4 mr-2" />
Force Push (Dangerous)
</Button>
)}
{showRebaseAndSync && (
<Button
onClick={handleRebaseAndSync}
variant="outline"
size="sm"
disabled={isRebaseActionPending}
className="mt-2 ml-2"
>
<GitMerge className="h-4 w-4 mr-2" />
Rebase and Sync
</Button>
)}
</div>
)}
{/* Conflict Resolver */}
{conflicts.length > 0 && (
//show a message that there are conflicts and to resolve them in Editor
<p className="text-sm text-red-600">
There are conflicts in the repository. Please resolve them in the
editor.
</p>
)}
{rebaseStatusMessage && (
<p className="text-sm text-gray-700 dark:text-gray-300 mt-2">
{rebaseStatusMessage}
</p>
)}
{syncSuccess && (
<p className="text-green-600 mt-2">Successfully pushed to GitHub!</p>
)}
......@@ -275,7 +517,7 @@ function ConnectedGitHubConnector({
</Button>
<Button
variant="destructive"
onClick={() => handleSyncToGithub(true)}
onClick={() => handleSyncToGithub({ force: true })}
disabled={isSyncing}
>
{isSyncing ? "Force Pushing..." : "Force Push"}
......@@ -586,6 +828,7 @@ export function UnconnectedGitHubConnector({
</svg>
)}
</Button>
{/* GitHub Connection Status/Instructions */}
{(githubUserCode || githubStatusMessage || githubError) && (
<div className="mt-6 p-4 border rounded-md bg-gray-50 dark:bg-gray-700/50 border-gray-200 dark:border-gray-600">
......
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { IpcClient } from "@/ipc/ipc_client";
import {
ChevronsDownUp,
ChevronsUpDown,
Network,
GitBranch,
Plus,
Trash2,
RefreshCw,
GitMerge,
Edit2,
MoreHorizontal,
AlertCircle,
} from "lucide-react";
import { useNavigate } from "@tanstack/react-router";
import { useSettings } from "@/hooks/useSettings";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Label } from "@/components/ui/label";
import { showSuccess, showError, showInfo } from "@/lib/toast";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
interface BranchManagerProps {
appId: number;
onBranchChange?: () => void;
}
export function GithubBranchManager({
appId,
onBranchChange,
}: BranchManagerProps) {
const { settings } = useSettings();
const navigate = useNavigate();
const [branches, setBranches] = useState<string[]>([]);
const [currentBranch, setCurrentBranch] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [newBranchName, setNewBranchName] = useState("");
const [isCreating, setIsCreating] = useState(false);
const [isSwitching, setIsSwitching] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [branchToDelete, setBranchToDelete] = useState<string | null>(null);
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [conflicts, setConflicts] = useState<string[]>([]);
// New state for features
const [sourceBranch, setSourceBranch] = useState<string>("");
const [branchToRename, setBranchToRename] = useState<string | null>(null);
const [renameBranchName, setRenameBranchName] = useState("");
const [isRenaming, setIsRenaming] = useState(false);
const [branchToMerge, setBranchToMerge] = useState<string | null>(null);
const [isMerging, setIsMerging] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
// State for abort confirmation dialog
const [abortConfirmation, setAbortConfirmation] = useState<{
show: boolean;
targetBranch: string;
operationType: "merge" | "rebase";
hasConflicts: boolean;
} | null>(null);
const loadBranches = useCallback(async () => {
setIsLoading(true);
try {
const [localResult, remoteBranches] = await Promise.all([
IpcClient.getInstance().listLocalGithubBranches(appId),
IpcClient.getInstance()
.listRemoteGithubBranches(appId)
.catch(() => []),
]);
// Merge local and remote branches, removing duplicates
const allBranches = new Set([...localResult.branches, ...remoteBranches]);
setBranches(Array.from(allBranches).sort());
setCurrentBranch(localResult.current || null);
} catch (error: any) {
showError(error.message || "Failed to load branches");
} finally {
setIsLoading(false);
}
}, [appId]);
useEffect(() => {
loadBranches();
}, [loadBranches]);
const handleCreateBranch = async () => {
if (!newBranchName.trim()) return;
setIsCreating(true);
const branchName = newBranchName.trim();
try {
await IpcClient.getInstance().createGithubBranch(
appId,
branchName,
sourceBranch || undefined,
);
showSuccess(`Branch '${branchName}' created`);
setNewBranchName("");
setSourceBranch(""); // Reset source branch selection
setShowCreateDialog(false);
await loadBranches();
// Automatically switch to the newly created branch
await handleSwitchBranch(branchName);
} catch (error: any) {
showError(error.message || "Failed to create branch");
} finally {
setIsCreating(false);
}
};
const handleSwitchBranch = async (branch: string) => {
if (branch === currentBranch) return;
setIsSwitching(true);
try {
const switchBranch = async () =>
await IpcClient.getInstance().switchGithubBranch(appId, branch);
try {
await switchBranch();
showSuccess(`Switched to branch '${branch}'`);
setCurrentBranch(branch);
onBranchChange?.();
return;
} catch (initialError: any) {
// Check for structured error codes instead of string matching
const errorCode = initialError?.code;
// Fallback: query backend git state if code is missing
let inferredCode:
| "REBASE_IN_PROGRESS"
| "MERGE_IN_PROGRESS"
| undefined;
if (!errorCode) {
try {
const state = await IpcClient.getInstance().getGithubState(appId);
if (state.rebaseInProgress) inferredCode = "REBASE_IN_PROGRESS";
else if (state.mergeInProgress) inferredCode = "MERGE_IN_PROGRESS";
} catch {
// ignore state inference errors
}
}
const effectiveCode = (errorCode || inferredCode) as
| "REBASE_IN_PROGRESS"
| "MERGE_IN_PROGRESS"
| undefined;
if (effectiveCode === "REBASE_IN_PROGRESS") {
// Check if there are unresolved conflicts
let hasConflicts = false;
try {
const conflicts =
await IpcClient.getInstance().getGithubMergeConflicts(appId);
hasConflicts = conflicts.length > 0;
} catch {
// If we can't get conflicts, assume there might be conflicts to be safe
hasConflicts = true;
}
// Show confirmation dialog instead of auto-aborting
setAbortConfirmation({
show: true,
targetBranch: branch,
operationType: "rebase",
hasConflicts,
});
return;
}
if (effectiveCode === "MERGE_IN_PROGRESS") {
// Check if there are unresolved conflicts
let hasConflicts = false;
try {
const conflicts =
await IpcClient.getInstance().getGithubMergeConflicts(appId);
hasConflicts = conflicts.length > 0;
} catch {
// If we can't get conflicts, assume there might be conflicts to be safe
hasConflicts = true;
}
// Show confirmation dialog instead of auto-aborting
setAbortConfirmation({
show: true,
targetBranch: branch,
operationType: "merge",
hasConflicts,
});
return;
}
throw initialError;
}
} catch (error: any) {
showError(error.message || "Failed to switch branch");
} finally {
setIsSwitching(false);
}
};
const handleConfirmAbortAndSwitch = async () => {
if (!abortConfirmation) return;
const { targetBranch, operationType } = abortConfirmation;
setIsSwitching(true);
try {
// Abort the operation - both methods throw on error
if (operationType === "rebase") {
await IpcClient.getInstance().abortGithubRebase(appId);
} else {
await IpcClient.getInstance().abortGithubMerge(appId);
}
// Now switch to the target branch
try {
await IpcClient.getInstance().switchGithubBranch(appId, targetBranch);
showSuccess(
`Aborted ongoing ${operationType} and switched to branch '${targetBranch}'`,
);
setCurrentBranch(targetBranch);
onBranchChange?.();
await loadBranches();
} catch (switchError: any) {
showError(
switchError?.message ||
`Failed to switch branch after aborting ${operationType}. Please try again.`,
);
}
} catch (abortError: any) {
showError(
abortError?.message ||
`Failed to abort ongoing ${operationType} before switching branches.`,
);
} finally {
setIsSwitching(false);
setAbortConfirmation(null);
}
};
const handleConfirmDeleteBranch = async () => {
if (!branchToDelete) return;
setIsDeleting(true);
try {
await IpcClient.getInstance().deleteGithubBranch(appId, branchToDelete);
showSuccess(`Branch '${branchToDelete}' deleted`);
setBranchToDelete(null);
await loadBranches();
} catch (error: any) {
showError(error.message || "Failed to delete branch");
} finally {
setIsDeleting(false);
}
};
const handleRenameBranch = async () => {
if (!branchToRename || !renameBranchName.trim()) return;
setIsRenaming(true);
try {
const trimmedNewName = renameBranchName.trim();
await IpcClient.getInstance().renameGithubBranch(
appId,
branchToRename,
trimmedNewName,
);
showSuccess(`Renamed '${branchToRename}' to '${trimmedNewName}'`);
setBranchToRename(null);
setRenameBranchName("");
await loadBranches();
} catch (error: any) {
showError(error.message || "Failed to rename branch");
} finally {
setIsRenaming(false);
}
};
const handleMergeBranch = async () => {
if (!branchToMerge) return;
setIsMerging(true);
setConflicts([]); // Clear conflicts when starting a new merge operation
try {
await IpcClient.getInstance().mergeGithubBranch(appId, branchToMerge);
showSuccess(`Merged '${branchToMerge}' into '${currentBranch}'`);
setConflicts([]); // Clear conflicts on successful merge
setBranchToMerge(null);
await loadBranches(); // Refresh to see any status changes if we implement them
} catch (error: any) {
// Check if it's a merge conflict error (handler converts GitConflictError to MergeConflictError)
const isConflict =
error?.name === "MergeConflictError" ||
error?.name === "GitConflictError";
if (isConflict) {
showInfo("Merge conflict detected. Please resolve them in the editor.");
// Show conflicts dialog
try {
const conflicts =
await IpcClient.getInstance().getGithubMergeConflicts(appId);
if (conflicts.length > 0) {
setConflicts(conflicts);
// Close the merge modal since user has been notified
setBranchToMerge(null);
return;
}
setConflicts([]);
showError(
"Merge conflict detected, but no conflicting files were returned. Please check git status and try again.",
);
} catch (fetchError: any) {
setConflicts([]);
showError(
fetchError.message ||
"Merge conflict detected, but failed to fetch conflicting files. Please try again.",
);
}
} else {
showError(error.message || "Failed to merge branch");
}
// Close the merge modal on any error since user has been notified
setBranchToMerge(null);
} finally {
setIsMerging(false);
}
};
return (
<div className="space-y-2">
<div className="flex gap-2">
<Select
value={currentBranch || ""}
onValueChange={handleSwitchBranch}
disabled={
isSwitching ||
isDeleting ||
isRenaming ||
isMerging ||
isCreating ||
isLoading
}
>
<SelectTrigger className="w-full" data-testid="branch-select-trigger">
<SelectValue placeholder="Select branch" />
</SelectTrigger>
<SelectContent>
{branches.map((branch) => (
<SelectItem key={branch} value={branch} aria-label={branch}>
<Network className="h-4 w-4 text-gray-500" />
<span className="font-medium text-sm">Branch:</span>
<span
data-testid="current-branch-display"
className="font-mono text-sm bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded"
>
{branch}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<TooltipProvider>
<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>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button
variant="outline"
size="icon"
title="Create new branch"
data-testid="create-branch-trigger"
>
<Plus className="h-4 w-4" />
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent>Create new branch</TooltipContent>
</Tooltip>
</TooltipProvider>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Branch</DialogTitle>
<DialogDescription>Create a new branch.</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-4">
<div>
<Label htmlFor="branch-name">Branch Name</Label>
<Input
id="branch-name"
value={newBranchName}
onChange={(e) => setNewBranchName(e.target.value)}
placeholder="feature/my-new-feature"
className="mt-2"
data-testid="new-branch-name-input"
/>
</div>
<div>
<Label htmlFor="source-branch">Source Branch</Label>
<Select value={sourceBranch} onValueChange={setSourceBranch}>
<SelectTrigger
className="mt-2"
data-testid="source-branch-select-trigger"
>
<SelectValue placeholder="Select source (optional, defaults to HEAD)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="HEAD">HEAD (Current)</SelectItem>
{branches.map((b) => (
<SelectItem key={b} value={b}>
{b}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowCreateDialog(false)}
>
Cancel
</Button>
<Button
onClick={handleCreateBranch}
disabled={isCreating || !newBranchName.trim()}
data-testid="create-branch-submit-button"
>
{isCreating ? "Creating..." : "Create Branch"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* Rename Dialog */}
<Dialog
open={!!branchToRename}
onOpenChange={(open) => !open && setBranchToRename(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Rename Branch</DialogTitle>
<DialogDescription>
Enter a new name for branch '{branchToRename}'.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<Label htmlFor="rename-branch-name">New Name</Label>
<Input
id="rename-branch-name"
value={renameBranchName}
onChange={(e) => setRenameBranchName(e.target.value)}
placeholder={branchToRename || ""}
className="mt-2"
data-testid="rename-branch-input"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setBranchToRename(null)}>
Cancel
</Button>
<Button
onClick={handleRenameBranch}
disabled={isRenaming || !renameBranchName.trim()}
data-testid="rename-branch-submit-button"
>
{isRenaming ? "Renaming..." : "Rename"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Merge Dialog */}
<Dialog
open={!!branchToMerge}
onOpenChange={(open) => !open && setBranchToMerge(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Merge Branch</DialogTitle>
<DialogDescription>
Are you sure you want to merge '{branchToMerge}' into '
{currentBranch}'?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setBranchToMerge(null)}>
Cancel
</Button>
<Button
onClick={handleMergeBranch}
disabled={isMerging}
data-testid="merge-branch-submit-button"
>
{isMerging ? "Merging..." : "Merge"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog
open={!!branchToDelete}
onOpenChange={(open) => !open && setBranchToDelete(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Branch</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete the branch '{branchToDelete}'. This
action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDeleteBranch}
disabled={isDeleting}
>
{isDeleting ? "Deleting..." : "Delete Branch"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Abort Merge/Rebase Confirmation Dialog */}
<AlertDialog
open={!!abortConfirmation?.show}
onOpenChange={(open) => {
if (!open) setAbortConfirmation(null);
}}
>
<AlertDialogContent className="max-w-lg">
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-3">
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-yellow-100 dark:bg-yellow-900/30">
<AlertCircle className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />
</span>
<div className="flex flex-col">
<span className="text-base font-semibold">
{abortConfirmation?.operationType === "merge"
? "Merge in Progress"
: "Rebase in Progress"}
</span>
<span className="text-sm text-muted-foreground font-normal">
This action will abort the current operation
</span>
</div>
</AlertDialogTitle>
<AlertDialogDescription className="mt-4 space-y-4 text-sm">
<p className="text-foreground">
A{" "}
<span className="font-medium">
{abortConfirmation?.operationType}
</span>{" "}
operation is currently in progress. Switching to{" "}
<span className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
{abortConfirmation?.targetBranch}
</span>{" "}
will abort this operation.
</p>
{abortConfirmation?.hasConflicts && (
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-red-700 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-400">
<p className="font-medium">Unresolved conflicts detected</p>
<p className="mt-1 text-xs">
Aborting will discard any conflict resolution work you’ve
already done.
</p>
</div>
)}
<p className="text-muted-foreground">
Are you sure you want to abort the{" "}
{abortConfirmation?.operationType} and switch branches?
</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="mt-6 gap-2">
<AlertDialogCancel
disabled={isSwitching}
data-testid="abort-confirmation-cancel"
>
Keep working
</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmAbortAndSwitch}
disabled={isSwitching}
className="bg-red-600 text-white hover:bg-red-700 focus:ring-red-600"
data-testid="abort-confirmation-proceed"
>
{isSwitching ? (
<span className="flex items-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
Aborting…
</span>
) : (
`Abort ${
abortConfirmation?.operationType === "merge"
? "Merge"
: "Rebase"
} & Switch`
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Conflict Resolver */}
{conflicts.length > 0 && (
<p className="text-sm text-red-600">
There are conflicts in the repository. Please resolve them in the
editor.
</p>
)}
<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">
<GitBranch className="w-5 h-5" />
<div>
<CardTitle className="text-sm" data-testid="branches-header">
Branches
</CardTitle>
<CardDescription className="text-xs">
Manage your branches, merge, delete, and more.
</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 pt-0">
{/* Banner for native git requirement */}
{!settings?.enableNativeGit && (
<Alert
variant="default"
className="border-amber-500/50 bg-amber-500/10"
>
<AlertCircle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<AlertTitle className="text-amber-900 dark:text-amber-100">
Native Git Required
</AlertTitle>
<AlertDescription className="text-amber-800 dark:text-amber-200">
<p className="mb-2">
Some Git actions (like rebase, merge abort, and advanced
branch operations) require Native Git to be enabled.
</p>
<Button
variant="outline"
size="sm"
onClick={() => navigate({ to: "/settings" })}
className="mt-2 border-amber-600 dark:border-amber-400 text-amber-900 dark:text-amber-100 hover:bg-amber-600/10"
>
Enable in Settings
</Button>
</AlertDescription>
</Alert>
)}
{/* List of other branches with delete option? Or just rely on Select? */}
{branches.length > 1 && (
<div className="mt-2">
<div className="space-y-1 max-h-40 overflow-y-auto border rounded-md p-2">
{branches.map((branch) => (
<div
key={branch}
className="flex items-center justify-between text-sm py-1 px-2 hover:bg-gray-50 dark:hover:bg-gray-800 rounded"
data-testid={`branch-item-${branch}`}
>
<span
className={
branch === currentBranch
? "font-bold text-blue-600"
: ""
}
>
{branch}
</span>
{branch !== currentBranch && (
<DropdownMenu
onOpenChange={(open) => {
if (open) setIsExpanded(true);
}}
>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
data-testid={`branch-actions-${branch}`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => setBranchToMerge(branch)}
data-testid="merge-branch-menu-item"
>
<GitMerge className="mr-2 h-4 w-4" />
Merge into {currentBranch}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setBranchToRename(branch);
setRenameBranchName(branch);
}}
data-testid="rename-branch-menu-item"
>
<Edit2 className="mr-2 h-4 w-4" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
className="text-red-600"
onClick={() => setBranchToDelete(branch)}
data-testid="delete-branch-menu-item"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
))}
</div>
</div>
)}
</CardContent>
</div>
</Card>
</div>
);
}
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);
}
import { ipcMain, BrowserWindow, IpcMainInvokeEvent } from "electron";
import fetch from "node-fetch"; // Use node-fetch for making HTTP requests in main process
import { writeSettings, readSettings } from "../../main/settings";
import { gitSetRemoteUrl, gitPush, gitClone } from "../utils/git_utils";
import {
gitSetRemoteUrl,
gitPush,
gitClone,
gitPull,
gitRebaseAbort,
gitRebaseContinue,
gitRebase,
gitFetch,
gitCreateBranch,
gitCheckout,
gitGetMergeConflicts,
gitCurrentBranch,
gitListBranches,
gitListRemoteBranches,
isGitStatusClean,
getCurrentCommitHash,
isGitMergeInProgress,
isGitRebaseInProgress,
GitConflictError,
} from "../utils/git_utils";
import * as schema from "../../db/schema";
import fs from "node:fs";
import { getDyadAppPath } from "../../paths/paths";
......@@ -12,7 +32,8 @@ import { eq } from "drizzle-orm";
import { GithubUser } from "../../lib/schemas";
import log from "electron-log";
import { IS_TEST_BUILD } from "../utils/test_utils";
import path from "node:path"; // ← ADD THIS
import path from "node:path";
import { withLock } from "../utils/lock_utils";
const logger = log.scope("github_handlers");
......@@ -85,6 +106,170 @@ export async function getGithubUser(): Promise<GithubUser | null> {
}
}
async function prepareLocalBranch({
appId,
branch,
remoteUrl,
accessToken,
}: {
appId: number;
branch?: string;
remoteUrl?: string;
accessToken?: 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 targetBranch = branch || "main";
try {
// Set up remote URL if provided (should be set up before calling this)
if (remoteUrl) {
await gitSetRemoteUrl({
path: appPath,
remoteUrl,
});
// Fetch remote branches if we have access token and remote URL
// This allows us to check if the branch exists remotely
if (accessToken) {
try {
await gitFetch({
path: appPath,
remote: "origin",
accessToken,
});
} catch (fetchError: any) {
// For new repos, fetch might fail because the repo is empty
// This is okay - we'll just create the branch locally
logger.debug(
`[GitHub Handler] Fetch failed (expected for new repos): ${fetchError?.message || "Unknown error"}`,
);
}
}
}
// Use locking to prevent race conditions when multiple operations attempt to modify the repository
// This ensures atomicity and prevents conflicts between concurrent operations
await withLock(appId, async () => {
// Check for uncommitted changes
await ensureCleanWorkspace(appPath, `preparing branch '${targetBranch}'`);
// List branches and check if target branch exists
const localBranches = await gitListBranches({ path: appPath });
// Check if branch exists remotely (if remote was set up)
let remoteBranches: string[] = [];
if (remoteUrl && accessToken) {
remoteBranches = await gitListRemoteBranches({
path: appPath,
remote: "origin",
});
}
if (!localBranches.includes(targetBranch)) {
// If branch exists remotely, create local tracking branch
// Otherwise, create a new local branch
if (remoteBranches.includes(targetBranch)) {
// For native git: create branch with tracking
// For isomorphic-git: checkout remote branch directly (creates tracking branch automatically)
const settings = readSettings();
if (settings.enableNativeGit) {
// Native git: create branch from remote with tracking
await gitCreateBranch({
path: appPath,
branch: targetBranch,
from: `origin/${targetBranch}`,
});
await gitCheckout({ path: appPath, ref: targetBranch });
} else {
// isomorphic-git: create local branch from the remote commit and checkout so branch name matches native git
// gitCreateBranch does not support 'from' when native git is disabled, so resolve the remote ref's commit
// and create the local branch at that commit.
const remoteRef = `refs/remotes/origin/${targetBranch}`;
let commitSha: string;
try {
commitSha = await getCurrentCommitHash({
path: appPath,
ref: remoteRef,
});
} catch {
// Fallback to short remote ref name if the full refs path isn't present
try {
commitSha = await getCurrentCommitHash({
path: appPath,
ref: `origin/${targetBranch}`,
});
} catch (innerErr: any) {
throw new Error(
`Failed to resolve remote branch 'origin/${targetBranch}' to a commit. ` +
"Ensure 'git fetch' succeeded and the remote branch exists. " +
`${innerErr?.message || String(innerErr)}`,
);
}
}
// Checkout the remote commit (detached HEAD), create branch at that commit, then checkout the branch
// Store current branch to restore on error
const previousBranch = await gitCurrentBranch({ path: appPath });
try {
await gitCheckout({ path: appPath, ref: commitSha });
await gitCreateBranch({ path: appPath, branch: targetBranch });
await gitCheckout({ path: appPath, ref: targetBranch });
} catch (error: any) {
// If anything fails, restore the previous branch to avoid leaving repo in detached HEAD
if (previousBranch) {
try {
await gitCheckout({ path: appPath, ref: previousBranch });
} catch (restoreError) {
logger.error(
`Failed to restore branch '${previousBranch}' after error: ${restoreError}`,
);
}
} else {
logger.warn(
"[GitHub Handler] Previous branch unknown; repository may remain in detached HEAD at " +
`${commitSha}.`,
);
}
throw error;
}
}
} else {
// Create new local branch
await gitCreateBranch({
path: appPath,
branch: targetBranch,
});
await gitCheckout({ path: appPath, ref: targetBranch });
}
} else {
// Branch exists locally, just checkout
await gitCheckout({ path: appPath, ref: targetBranch });
}
});
} catch (gitError: any) {
logger.error("[GitHub Handler] Failed to prepare local branch:", gitError);
// Check if error is about uncommitted changes (fallback in case check above missed it)
const errorMessage =
gitError?.message ||
"Failed to prepare local branch for the connected repository.";
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 prepare local branch: uncommitted changes detected. ` +
"Unable to automatically handle uncommitted changes. Please commit or stash your changes manually and try again.",
);
}
throw new Error(errorMessage);
}
}
// function event.sender.send(channel: string, data: any) {
// if (currentFlowState?.window && !currentFlowState.window.isDestroyed()) {
// currentFlowState.window.webContents.send(channel, data);
......@@ -501,6 +686,20 @@ async function handleCreateRepo(
throw new Error(errorMessage);
}
// Set up remote URL before preparing branch
const remoteUrl = IS_TEST_BUILD
? `${GITHUB_GIT_BASE}/${owner}/${repo}.git`
: `https://${accessToken}:x-oauth-basic@github.com/${owner}/${repo}.git`;
// Prepare local branch with remote URL set up
await prepareLocalBranch({
appId,
branch,
remoteUrl,
accessToken,
});
// Store org, repo, and branch in the app's DB row (apps table)
await updateAppGithubRepo({ appId, org: owner, repo, branch });
}
......@@ -541,6 +740,19 @@ async function handleConnectToExistingRepo(
);
}
// Set up remote URL before preparing branch
const remoteUrl = IS_TEST_BUILD
? `${GITHUB_GIT_BASE}/${owner}/${repo}.git`
: `https://${accessToken}:x-oauth-basic@github.com/${owner}/${repo}.git`;
// Prepare local branch with remote URL set up
await prepareLocalBranch({
appId,
branch,
remoteUrl,
accessToken,
});
// Store org, repo, and branch in the app's DB row
await updateAppGithubRepo({ appId, org: owner, repo, branch });
} catch (err: any) {
......@@ -552,49 +764,362 @@ async function handleConnectToExistingRepo(
// --- GitHub Push Handler ---
async function handlePushToGithub(
event: IpcMainInvokeEvent,
{ appId, force }: { appId: number; force?: boolean },
) {
{
appId,
force,
forceWithLease,
}: {
appId: number;
force?: boolean;
forceWithLease?: boolean;
},
): Promise<void> {
// Get access token from settings
const settings = readSettings();
const accessToken = settings.githubAccessToken?.value;
if (!accessToken) {
throw new Error("Not authenticated with GitHub.");
}
// Get app info from DB
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 branch = app.githubBranch || "main";
// Set up remote URL with token
const remoteUrl = IS_TEST_BUILD
? `${GITHUB_GIT_BASE}/${app.githubOrg}/${app.githubRepo}.git`
: `https://${accessToken}:x-oauth-basic@github.com/${app.githubOrg}/${app.githubRepo}.git`;
// Set or update remote URL using git config
await gitSetRemoteUrl({
path: appPath,
remoteUrl,
});
// Pull changes first (unless force push)
if (!force && !forceWithLease) {
try {
await gitPull({
path: appPath,
remote: "origin",
branch,
accessToken,
});
} catch (pullError: any) {
// Check if it's a conflict error (including GitConflictError)
if ((pullError as any)?.name === "GitConflictError") {
throw GitConflictError(
"Merge conflict detected during pull. Please resolve conflicts before pushing.",
);
}
// Check for conflict in error message
const errorMessage = pullError?.message || "";
if (
errorMessage.includes("merge conflict") ||
errorMessage.includes("Merge conflict") ||
errorMessage.includes("CONFLICT (") ||
errorMessage.match(/failed to merge.*conflict/i)
) {
throw GitConflictError(
"Merge conflict detected during pull. Please resolve conflicts before pushing.",
);
}
// Check if it's a missing remote branch error
const isMissingRemoteBranch =
pullError?.code === "MissingRefError" ||
(pullError?.code === "NotFoundError" &&
(errorMessage.includes("remote ref") ||
errorMessage.includes("remote branch"))) ||
errorMessage.includes("couldn't find remote ref") ||
// isomorphic-git throws a TypeError when the remote repo is empty
errorMessage.includes("Cannot read properties of null");
// If it's just that remote doesn't have the branch yet, we can ignore and push
if (!isMissingRemoteBranch) {
throw pullError;
} else {
logger.debug(
"[GitHub Handler] Remote branch missing during pull, continuing with push",
errorMessage,
);
}
}
}
// Push to GitHub
await gitPush({
path: appPath,
branch,
accessToken,
force,
forceWithLease,
});
}
async function handleAbortRebase(
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 gitRebaseAbort({ path: appPath });
}
async function handleContinueRebase(
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 gitRebaseContinue({ path: appPath });
}
// --- GitHub Rebase Handler ---
async function handleRebaseFromGithub(
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);
const branch = app.githubBranch || "main";
// Set up remote URL with token
const remoteUrl = IS_TEST_BUILD
? `${GITHUB_GIT_BASE}/${app.githubOrg}/${app.githubRepo}.git`
: `https://${accessToken}:x-oauth-basic@github.com/${app.githubOrg}/${app.githubRepo}.git`;
// Set or update remote URL using git config
await gitSetRemoteUrl({
path: appPath,
remoteUrl,
});
// Fetch latest changes from remote first
await gitFetch({
path: appPath,
remote: "origin",
accessToken,
});
// Check for uncommitted changes - Git requires a clean working directory for rebase
await withLock(appId, async () => {
await ensureCleanWorkspace(appPath, "rebase");
});
// Perform the rebase
await gitRebase({
path: appPath,
branch,
});
}
/**
* Ensures the git workspace is clean before continuing an operation.
*/
export async function ensureCleanWorkspace(
appPath: string,
operationDescription: string,
): Promise<void> {
const isClean = await isGitStatusClean({ path: appPath });
if (isClean) return;
throw new Error(
`Workspace is not clean before ${operationDescription}. ` +
"Please commit or stash your changes manually and try again.",
);
}
async function handleGetGitState(
event: IpcMainInvokeEvent,
{ appId }: { appId: number },
): Promise<{ mergeInProgress: boolean; rebaseInProgress: boolean }> {
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 mergeInProgress = isGitMergeInProgress({ path: appPath });
const rebaseInProgress = isGitRebaseInProgress({ path: appPath });
return { mergeInProgress, rebaseInProgress };
}
async function handleListCollaborators(
event: IpcMainInvokeEvent,
{ appId }: { appId: number },
): Promise<{ login: string; avatar_url: string; permissions: any }[]> {
try {
// Get access token from settings
const settings = readSettings();
const accessToken = settings.githubAccessToken?.value;
if (!accessToken) {
return { success: false, error: "Not authenticated with GitHub." };
throw new Error("Not authenticated with GitHub.");
}
// Get app info from DB
const app = await db.query.apps.findFirst({ where: eq(apps.id, appId) });
if (!app || !app.githubOrg || !app.githubRepo) {
return { success: false, error: "App is not linked to a GitHub repo." };
throw new Error("App is not linked to a GitHub repo.");
}
const appPath = getDyadAppPath(app.path);
const branch = app.githubBranch || "main";
// Set up remote URL with token
const remoteUrl = IS_TEST_BUILD
? `${GITHUB_GIT_BASE}/${app.githubOrg}/${app.githubRepo}.git`
: `https://${accessToken}:x-oauth-basic@github.com/${app.githubOrg}/${app.githubRepo}.git`;
// Set or update remote URL using git config
await gitSetRemoteUrl({
path: appPath,
remoteUrl,
});
const response = await fetch(
`${GITHUB_API_BASE}/repos/${app.githubOrg}/${app.githubRepo}/collaborators`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/vnd.github+json",
},
},
);
// Push to GitHub
await gitPush({
path: appPath,
branch,
accessToken,
force,
});
return { success: true };
if (!response.ok) {
throw new Error(
`Failed to list collaborators: ${response.status} ${response.statusText}`,
);
}
const collaborators = await response.json();
return collaborators.map((c: any) => ({
login: c.login,
avatar_url: c.avatar_url,
permissions: c.permissions,
}));
} catch (err: any) {
return {
success: false,
error: err.message || "Failed to push to GitHub.",
};
logger.error("[GitHub Handler] Failed to list collaborators:", err);
throw new Error(err.message || "Failed to list collaborators.");
}
}
async function handleInviteCollaborator(
event: IpcMainInvokeEvent,
{ appId, username }: { appId: number; username: string },
): Promise<void> {
try {
// Validate username
const trimmedUsername = username.trim();
if (!trimmedUsername) {
throw new Error("Username cannot be empty.");
}
if (trimmedUsername.length > 39) {
throw new Error("GitHub username cannot exceed 39 characters.");
}
// Single character usernames must be alphanumeric only
if (trimmedUsername.length === 1) {
if (!/^[a-zA-Z0-9]$/.test(trimmedUsername)) {
throw new Error(
"Invalid GitHub username format. Single-character usernames must be alphanumeric.",
);
}
} else {
// Multi-character usernames: alphanumeric start, can contain hyphens in middle, alphanumeric end
if (!/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/.test(trimmedUsername)) {
throw new Error(
"Invalid GitHub username format. Usernames can only contain alphanumeric characters and hyphens, and cannot start or end with a hyphen.",
);
}
}
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.");
}
// GitHub API to add a collaborator (sends an invitation)
const response = await fetch(
`${GITHUB_API_BASE}/repos/${app.githubOrg}/${app.githubRepo}/collaborators/${encodeURIComponent(trimmedUsername)}`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/vnd.github+json",
},
body: JSON.stringify({
permission: "push", // Default to write access
}),
},
);
if (!response.ok) {
const data = await response.json();
throw new Error(
data.message ||
`Failed to invite collaborator: ${response.status} ${response.statusText}`,
);
}
} catch (err: any) {
logger.error("[GitHub Handler] Failed to invite collaborator:", err);
throw new Error(err.message || "Failed to invite collaborator.");
}
}
async function handleRemoveCollaborator(
event: IpcMainInvokeEvent,
{ appId, username }: { appId: number; username: string },
): Promise<void> {
try {
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 response = await fetch(
`${GITHUB_API_BASE}/repos/${app.githubOrg}/${app.githubRepo}/collaborators/${encodeURIComponent(username)}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/vnd.github+json",
},
},
);
if (!response.ok) {
const data = await response.json();
throw new Error(
data.message ||
`Failed to remove collaborator: ${response.status} ${response.statusText}`,
);
}
} catch (err: any) {
logger.error("[GitHub Handler] Failed to remove collaborator:", err);
throw new Error(err.message || "Failed to remove collaborator.");
}
}
async function handleGetMergeConflicts(
event: IpcMainInvokeEvent,
{ appId }: { appId: number },
): 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 conflicts = await gitGetMergeConflicts({ path: appPath });
return conflicts;
}
async function handleDisconnectGithubRepo(
event: IpcMainInvokeEvent,
{ appId }: { appId: number },
......@@ -746,6 +1271,14 @@ export function registerGithubHandlers() {
) => handleConnectToExistingRepo(event, args),
);
ipcMain.handle("github:push", handlePushToGithub);
ipcMain.handle("github:rebase", handleRebaseFromGithub);
ipcMain.handle("github:rebase-abort", handleAbortRebase);
ipcMain.handle("github:rebase-continue", handleContinueRebase);
ipcMain.handle("github:list-collaborators", handleListCollaborators);
ipcMain.handle("github:invite-collaborator", handleInviteCollaborator);
ipcMain.handle("github:remove-collaborator", handleRemoveCollaborator);
ipcMain.handle("github:get-conflicts", handleGetMergeConflicts);
ipcMain.handle("github:get-git-state", handleGetGitState);
ipcMain.handle("github:disconnect", (event, args: { appId: number }) =>
handleDisconnectGithubRepo(event, args),
);
......
......@@ -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;
......
......@@ -23,6 +23,11 @@ import type {
GitInitParams,
GitPushParams,
GitCommit,
GitFetchParams,
GitPullParams,
GitMergeParams,
GitCreateBranchParams,
GitDeleteBranchParams,
} from "../git_types";
/**
......@@ -331,6 +336,22 @@ export async function gitAdd({ path, filepath }: GitFileParams): Promise<void> {
}
}
export async function gitReset({ path }: GitBaseParams): Promise<void> {
const settings = readSettings();
if (settings.enableNativeGit) {
// Reset the staging area to match HEAD (unstage files but keep working directory changes)
await execOrThrow(["reset", "HEAD"], path, "Failed to reset staging area");
} else {
// For isomorphic-git, resetting the index is complex and not directly supported
// This is a fallback - in practice, this should rarely be needed when native git is disabled
// If needed, users can manually reset via command line or enable native git
throw new Error(
"gitReset: Resetting the staging area is not fully supported when native git is disabled. " +
"Please enable native git or manually unstage files using 'git reset HEAD'.",
);
}
}
export async function gitInit({
path,
ref = "main",
......@@ -461,6 +482,45 @@ export async function gitListBranches({
}
}
export async function gitListRemoteBranches({
path,
remote = "origin",
}: GitBaseParams & { remote?: string }): Promise<string[]> {
const settings = readSettings();
if (settings.enableNativeGit) {
const result = await exec(["branch", "-r", "--list"], path);
if (result.exitCode !== 0) {
throw new Error(result.stderr.toString());
}
// Parse output:
// e.g. " origin/main\n origin/feature/login\n upstream/develop"
// Only return branches from the specified remote
return result.stdout
.toString()
.split("\n")
.map((line) => {
const trimmed = line.trim();
if (trimmed.startsWith(`${remote}/`)) {
return trimmed.substring(`${remote}/`.length);
}
return null;
})
.filter(
(line): line is string =>
line !== null && line.length > 0 && !line.includes("HEAD"),
);
} else {
const allBranches = await git.listBranches({
fs,
dir: path,
remote: remote,
});
return allBranches;
}
}
export async function gitRenameBranch({
path,
oldBranch,
......@@ -475,11 +535,42 @@ export async function gitRenameBranch({
throw new Error(result.stderr.toString());
}
} else {
await git.renameBranch({
// isomorphic-git does not have a renameBranch function.
// We implement it by resolving the ref, writing a new ref, and deleting the old one.
// 1. Check if we are currently on the branch being renamed
const current = await git.currentBranch({ fs, dir: path });
// 2. Resolve the commit hash of the old branch
const oid = await git.resolveRef({
fs,
dir: path,
ref: oldBranch,
});
// 3. Create the new branch pointing to the same commit
await git.writeRef({
fs,
dir: path,
ref: `refs/heads/${newBranch}`,
value: oid,
force: false,
});
// 4. If we were on the old branch, switch HEAD to the new branch
if (current === oldBranch) {
await git.checkout({
fs,
dir: path,
ref: newBranch,
});
}
// 5. Delete the old branch
await git.deleteBranch({
fs,
dir: path,
oldref: oldBranch,
ref: newBranch,
ref: oldBranch,
});
}
}
......@@ -565,12 +656,20 @@ export async function gitSetRemoteUrl({
}
} else {
//isomorphic-git version
// Set the remote URL
await git.setConfig({
fs,
dir: path,
path: "remote.origin.url",
value: remoteUrl,
});
// Set the fetch refspec (required for isomorphic-git to work with remotes)
await git.setConfig({
fs,
dir: path,
path: "remote.origin.fetch",
value: "+refs/heads/*:refs/remotes/origin/*",
});
}
}
......@@ -579,43 +678,123 @@ export async function gitPush({
branch,
accessToken,
force,
forceWithLease,
}: GitPushParams): Promise<void> {
const settings = readSettings();
const targetBranch = branch || "main";
if (settings.enableNativeGit) {
// Dugite version
try {
// Push using the configured origin remote (which already has auth in URL)
const args = ["push", "origin", `main:${branch}`];
if (force) {
const args = ["push", "origin", `${targetBranch}:${targetBranch}`];
if (forceWithLease) {
args.push("--force-with-lease");
} else if (force) {
args.push("--force");
}
const result = await exec(args, path);
if (result.exitCode !== 0) {
const errorMsg = result.stderr.toString() || result.stdout.toString();
throw new Error(`Git push failed: ${errorMsg}`);
}
return;
} catch (error: any) {
logger.error("Error during git push:", error);
throw new Error(`Git push failed: ${error.message}`);
}
} else {
// isomorphic-git version
await git.push({
fs,
http,
dir: path,
remote: "origin",
ref: "main",
remoteRef: branch,
onAuth: () => ({
username: accessToken,
password: "x-oauth-basic",
}),
force: !!force,
});
}
// isomorphic-git cannot provide "force-with-lease" safety guarantees.
if (forceWithLease) {
logger.warn(
"gitPush: 'forceWithLease' requested but not supported when native git is disabled. " +
"Rejecting push to prevent unsafe force operation.",
);
throw new Error(
"gitPush: 'forceWithLease' is not supported when native git is disabled. " +
"Falling back to plain force could overwrite remote commits. Enable native git.",
);
}
await git.push({
fs,
http,
dir: path,
remote: "origin",
ref: targetBranch,
remoteRef: targetBranch,
onAuth: accessToken
? () => ({
username: accessToken,
password: "x-oauth-basic",
})
: undefined,
force: !!force,
});
}
export async function gitRebaseAbort({ path }: GitBaseParams): Promise<void> {
const settings = readSettings();
if (!settings.enableNativeGit) {
throw new Error(
"Rebase controls require native Git. Enable native Git in settings.",
);
}
await execOrThrow(["rebase", "--abort"], path, "Failed to abort rebase");
}
export async function gitRebaseContinue({
path,
}: GitBaseParams): Promise<void> {
const settings = readSettings();
if (!settings.enableNativeGit) {
throw new Error(
"Rebase controls require native Git. Enable native Git in settings.",
);
}
// Use withGitAuthor since rebase --continue needs to create commits
// and requires user.name and user.email
const args = await withGitAuthor(["rebase", "--continue"]);
await execOrThrow(
args,
path,
"Failed to continue rebase. Make sure conflicts are resolved and changes are staged.",
);
}
export async function gitRebase({
path,
branch,
}: {
path: string;
branch: string;
}): Promise<void> {
const settings = readSettings();
if (!settings.enableNativeGit) {
throw new Error(
"Rebase requires native Git. Enable native Git in settings.",
);
}
// Use withGitAuthor since rebase replays commits and needs user.name and user.email
// to set the committer identity on the rebased commits
const args = await withGitAuthor(["rebase", `origin/${branch}`]);
await execOrThrow(
args,
path,
`Failed to rebase onto origin/${branch}. Make sure you have a clean working directory and the remote branch exists.`,
);
}
export async function gitMergeAbort({ path }: GitBaseParams): Promise<void> {
const settings = readSettings();
if (!settings.enableNativeGit) {
throw new Error(
"Merge abort requires native Git. Enable native Git in settings.",
);
}
await execOrThrow(["merge", "--abort"], path, "Failed to abort merge");
}
export async function gitCurrentBranch({
......@@ -743,3 +922,306 @@ export async function gitLogNative(
return entries;
}
export async function gitFetch({
path,
remote = "origin",
accessToken,
}: GitFetchParams): Promise<void> {
const settings = readSettings();
if (settings.enableNativeGit) {
await execOrThrow(["fetch", remote], path, "Failed to fetch from remote");
} else {
await git.fetch({
fs,
http,
dir: path,
remote,
onAuth: accessToken
? () => ({
username: accessToken,
password: "x-oauth-basic",
})
: undefined,
});
}
}
// Custom error function for git conflicts
export function GitConflictError(message: string): Error {
const error = new Error(message);
error.name = "GitConflictError";
return error;
}
// Custom error function for git operations with structured error codes
export function GitStateError(message: string, code: string): Error {
const error = new Error(message);
error.name = "GitStateError";
(error as any).code = code;
return error;
}
// Error codes for git state errors
export const GIT_ERROR_CODES = {
MERGE_IN_PROGRESS: "MERGE_IN_PROGRESS",
REBASE_IN_PROGRESS: "REBASE_IN_PROGRESS",
} as const;
function hasGitConflictState({ path }: GitBaseParams): boolean {
return isGitMergeOrRebaseInProgress({ path });
}
export async function gitPull({
path,
remote = "origin",
branch = "main",
accessToken,
author,
}: GitPullParams): Promise<void> {
const settings = readSettings();
if (settings.enableNativeGit) {
// Use withGitAuthor since pull may need to create merge commits
// and requires user.name and user.email
const pullArgs = await withGitAuthor([
"-c",
"credential.helper=",
"pull",
"--rebase=false",
remote,
branch,
]);
try {
await execOrThrow(pullArgs, path, "Failed to pull from remote");
} catch (error: any) {
// Check git state files to detect conflicts instead of parsing error messages
if (hasGitConflictState({ path })) {
throw GitConflictError(
`Merge conflict detected during pull. Please resolve conflicts before proceeding.`,
);
}
throw error;
}
return;
}
try {
await git.pull({
fs,
http,
dir: path,
remote,
ref: branch,
singleBranch: true,
author: author || (await getGitAuthor()),
onAuth: accessToken
? () => ({
username: accessToken,
password: "x-oauth-basic",
})
: undefined,
});
// Check for conflicts even if pull succeeded (isomorphic-git may not throw on conflicts)
if (hasGitConflictState({ path })) {
throw GitConflictError(
`Merge conflict detected during pull. Please resolve conflicts before proceeding.`,
);
}
} catch (error: any) {
// Check git state files to detect conflicts instead of parsing error messages
if (hasGitConflictState({ path })) {
throw GitConflictError(
`Merge conflict detected during pull. Please resolve conflicts before proceeding.`,
);
}
throw error;
}
}
export async function gitMerge({
path,
branch,
author,
}: GitMergeParams): Promise<void> {
const settings = readSettings();
if (settings.enableNativeGit) {
// Use withGitAuthor since merge may need to create merge commits
// and requires user.name and user.email
const args = await withGitAuthor(["merge", branch]);
try {
await execOrThrow(args, path, `Failed to merge branch ${branch}`);
} catch (error: any) {
// Check git state files to detect conflicts instead of parsing error messages
if (hasGitConflictState({ path })) {
throw GitConflictError(
`Merge conflict detected during merge. Please resolve conflicts before proceeding.`,
);
}
throw error;
}
return;
}
try {
await git.merge({
fs,
dir: path,
ours: "HEAD",
theirs: branch,
author: author || (await getGitAuthor()),
});
// Check for conflicts even if merge succeeded (isomorphic-git may not throw on conflicts)
if (hasGitConflictState({ path })) {
throw GitConflictError(
`Merge conflict detected during merge. Please resolve conflicts before proceeding.`,
);
}
} catch (error: any) {
// Check git state files to detect conflicts instead of parsing error messages
if (hasGitConflictState({ path })) {
throw GitConflictError(
`Merge conflict detected during merge. Please resolve conflicts before proceeding.`,
);
}
throw error;
}
}
export async function gitCreateBranch({
path,
branch,
from = "HEAD",
}: GitCreateBranchParams): Promise<void> {
const settings = readSettings();
if (settings.enableNativeGit) {
await execOrThrow(
["branch", branch, from],
path,
`Failed to create branch ${branch}`,
);
return;
}
// isomorphic-git: branch creation uses the current HEAD; it does not honor "from"
// in the same way as native `git branch <name> <from>`.
if (from !== "HEAD") {
throw new Error(
`gitCreateBranch: 'from' is not supported when native git is disabled (from=${from}). ` +
`Branches would be created from HEAD instead.`,
);
}
await git.branch({
fs,
dir: path,
ref: branch,
checkout: false,
});
}
export async function gitDeleteBranch({
path,
branch,
}: GitDeleteBranchParams): Promise<void> {
const settings = readSettings();
if (settings.enableNativeGit) {
await execOrThrow(
["branch", "-D", branch],
path,
`Failed to delete branch ${branch}`,
);
} else {
await git.deleteBranch({
fs,
dir: path,
ref: branch,
});
}
}
export async function gitGetMergeConflicts({
path,
}: GitBaseParams): Promise<string[]> {
const settings = readSettings();
if (settings.enableNativeGit) {
// git diff --name-only --diff-filter=U
const result = (await exec(
["diff", "--name-only", "--diff-filter=U"],
path,
)) as unknown as {
stdout: string;
stderr: string;
exitCode: number;
};
if (result.exitCode !== 0) {
throw new Error(`Failed to get merge conflicts: ${result.stderr}`);
}
return result.stdout
.toString()
.split("\n")
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
//throw error("gitGetMergeConflicts requires native Git. Enable native Git in settings.");
throw new Error(
"Git conflict detection requires native Git. Enable native Git in settings.",
);
}
/**
* Check if Git is currently in a merge or rebase state.
* This is important because commits are not allowed during merge/rebase
* if there are still unmerged files.
*/
export function isGitMergeOrRebaseInProgress({ path }: GitBaseParams): boolean {
const gitDir = pathModule.join(path, ".git");
// Check for merge in progress
const mergeHeadPath = pathModule.join(gitDir, "MERGE_HEAD");
if (fs.existsSync(mergeHeadPath)) {
return true;
}
// Check for rebase in progress
const rebaseHeadPath = pathModule.join(gitDir, "REBASE_HEAD");
if (fs.existsSync(rebaseHeadPath)) {
return true;
}
// Check for rebase-apply or rebase-merge directories
const rebaseApplyPath = pathModule.join(gitDir, "rebase-apply");
const rebaseMergePath = pathModule.join(gitDir, "rebase-merge");
if (fs.existsSync(rebaseApplyPath) || fs.existsSync(rebaseMergePath)) {
return true;
}
return false;
}
/**
* Check if Git is currently in a merge state (not a rebase).
* This checks for MERGE_HEAD file which indicates a merge is in progress.
*/
export function isGitMergeInProgress({ path }: GitBaseParams): boolean {
const gitDir = pathModule.join(path, ".git");
const mergeHeadPath = pathModule.join(gitDir, "MERGE_HEAD");
return fs.existsSync(mergeHeadPath);
}
/**
* Check if Git is currently in a rebase state (not a merge).
* This is used to determine whether to use `git rebase --continue`
* or `git commit` when completing conflict resolution.
*/
export function isGitRebaseInProgress({ path }: GitBaseParams): boolean {
const gitDir = pathModule.join(path, ".git");
// Check for rebase in progress via REBASE_HEAD
const rebaseHeadPath = pathModule.join(gitDir, "REBASE_HEAD");
if (fs.existsSync(rebaseHeadPath)) {
return true;
}
// Check for rebase-apply or rebase-merge directories
const rebaseApplyPath = pathModule.join(gitDir, "rebase-apply");
const rebaseMergePath = pathModule.join(gitDir, "rebase-merge");
if (fs.existsSync(rebaseApplyPath) || fs.existsSync(rebaseMergePath)) {
return true;
}
return false;
}
......@@ -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 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论