Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 22 additions & 8 deletions src/renderer/actions/gitActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { readBridge } from "@/renderer/bridge";
import { captureRendererException } from "@/renderer/diagnostics/sentry";
import { useAppStore } from "@/renderer/state/appStore";
import { usePanelStore } from "@/renderer/state/panelStore";
import { usePullFromSourceDialogStore } from "@/renderer/state/pullFromSourceDialogStore";
import { useSharedSettings } from "@/renderer/state/sharedSettingsStore";
import { resolveWorktreeBranch } from "@/renderer/utils/gitHelpers";
import { closeThreads } from "@/renderer/utils/shellUtils";
Expand All @@ -12,6 +13,17 @@ function captureGitActionError(error: unknown): void {
captureRendererException(error, { featureArea: "git" });
}

export function openGitReviewForWorktree(projectId: string, worktreePath: string): void {
const mode = useSharedSettings.getState().gitReviewMode;
const panelStore = usePanelStore.getState();
panelStore.setGitReviewContext({ projectId, worktreePath });
if (mode === "panel") {
panelStore.setGitReviewAsPanel(true);
} else {
panelStore.setGitOverlayOpen(true);
}
}

export function gitSync(projectId: string, worktreePath?: string): void {
const project = useAppStore.getState().projects.find((p) => p.id === projectId);
if (!project) return;
Expand Down Expand Up @@ -141,16 +153,18 @@ export function gitPullFromSource(projectId: string, worktreePath: string): void
const result = await readBridge().gitPullFromSource({
worktreeLocation,
sourceBranch,
preserveLocalChanges: false,
});
if (result.needsStash) {
usePullFromSourceDialogStore.getState().setDialog({
projectId,
worktreePath,
sourceBranch,
});
return;
}
if (result.conflicting) {
const mode = useSharedSettings.getState().gitReviewMode;
const panelStore = usePanelStore.getState();
panelStore.setGitReviewContext({ projectId, worktreePath });
if (mode === "panel") {
panelStore.setGitReviewAsPanel(true);
} else {
panelStore.setGitOverlayOpen(true);
}
openGitReviewForWorktree(projectId, worktreePath);
}
} catch (error) {
captureGitActionError(error);
Expand Down
20 changes: 20 additions & 0 deletions src/renderer/state/pullFromSourceDialogStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { create } from "zustand";

export interface PullFromSourceDialogState {
projectId: string;
worktreePath: string;
sourceBranch: string;
onComplete?: () => void;
}

interface PullFromSourceDialogStore {
dialog: PullFromSourceDialogState | null;
setDialog: (dialog: PullFromSourceDialogState) => void;
closeDialog: () => void;
}

export const usePullFromSourceDialogStore = create<PullFromSourceDialogStore>()((set) => ({
dialog: null,
setDialog: (dialog) => set({ dialog }),
closeDialog: () => set({ dialog: null }),
}));
9 changes: 9 additions & 0 deletions src/renderer/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@
@import "@heroui/styles";
@import "@xterm/xterm/css/xterm.css";

:root {
--lightcode-modal-radius: 0.7rem;
}

.modal__dialog:not(.modal__dialog--cover):not(.modal__dialog--full),
.alert-dialog__dialog:not(.alert-dialog__dialog--cover) {
border-radius: var(--lightcode-modal-radius);
}

@property --lc-chat-bottom-mask-end-alpha {
syntax: "<number>";
initial-value: 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { readBridge } from "@/renderer/bridge";
import { captureProductEvent } from "@/renderer/analytics/posthog";
import { useAgentStatusesStore } from "@/renderer/state/agentStatusesStore";
import { useGitStore } from "@/renderer/state/gitStore";
import { usePullFromSourceDialogStore } from "@/renderer/state/pullFromSourceDialogStore";
import { useSharedSettings } from "@/renderer/state/sharedSettingsStore";
import {
generateCommitMessageWithFallback,
Expand Down Expand Up @@ -357,7 +358,17 @@ export function useGitReviewActions(args: UseGitReviewActionsArgs) {
const result = await readBridge().gitPullFromSource({
worktreeLocation: getWorktreeLocation(),
sourceBranch,
preserveLocalChanges: false,
});
if (result.needsStash && worktreePath) {
usePullFromSourceDialogStore.getState().setDialog({
projectId: project.id,
worktreePath,
sourceBranch,
onComplete: onRefresh,
});
return;
}
if (result.conflicting) {
onRefresh();
return;
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/views/MainView/MainView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { useBrowserSync } from "@/renderer/views/MainView/parts/RightPanel/parts

import { AppOverlays } from "@/renderer/views/MainView/parts/AppOverlays";
import { WorktreeDeleteDialogs } from "@/renderer/views/MainView/parts/WorktreeDeleteDialogs";
import { PullFromSourceDialog } from "@/renderer/views/MainView/parts/PullFromSourceDialog";
import { MainPageLayout, StalePanelCleanup } from "@/renderer/views/MainView/parts/MainPageLayout";
import { ThreadSearchOverlayHost } from "@/renderer/views/ThreadSearchOverlay/ThreadSearchOverlay";

Expand Down Expand Up @@ -113,6 +114,7 @@ export function MainView(props: { storeHydrated: boolean; loadT0: number }) {
<StalePanelCleanup />
<AppOverlays />
<WorktreeDeleteDialogs />
<PullFromSourceDialog />
</>
);
}
92 changes: 92 additions & 0 deletions src/renderer/views/MainView/parts/PullFromSourceDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { useState } from "react";
import { AlertDialog, toast } from "@heroui/react";
import { buildWorktreeLocation } from "@/shared/worktree";
import { friendlyError, msg } from "@/shared/messages";
import { readBridge } from "@/renderer/bridge";
import { openGitReviewForWorktree } from "@/renderer/actions/gitActions";
import { Button } from "@/renderer/components/common/Button";
import { useAppStore } from "@/renderer/state/appStore";
import { usePullFromSourceDialogStore } from "@/renderer/state/pullFromSourceDialogStore";

export function PullFromSourceDialog() {
const dialog = usePullFromSourceDialogStore((s) => s.dialog);
const closeDialog = usePullFromSourceDialogStore((s) => s.closeDialog);
const project = useAppStore((s) => s.projects.find((p) => p.id === dialog?.projectId));
const [isPulling, setIsPulling] = useState(false);

if (!dialog || !project) return null;

const activeDialog = dialog;
const activeProject = project;

function handleClose() {
closeDialog();
}

async function handleStashPullReapply() {
setIsPulling(true);
try {
const stashPreservedMessage = msg("git.pull.stashPreserved");
const result = await readBridge().gitPullFromSource({
worktreeLocation: buildWorktreeLocation(activeProject.location, activeDialog.worktreePath),
sourceBranch: activeDialog.sourceBranch,
preserveLocalChanges: true,
});
closeDialog();
if (result.conflicting) {
const detail = result.conflictFiles?.length
? `\nConflicts:\n${result.conflictFiles.join("\n")}`
: "";
const stashNote = result.stashPreserved ? `\n${stashPreservedMessage}` : "";
toast.danger((result.error ?? msg("git.merge.conflicts")) + detail + stashNote);
openGitReviewForWorktree(activeDialog.projectId, activeDialog.worktreePath);
activeDialog.onComplete?.();
return;
}
if (!result.merged) {
const fallback = msg("git.pull.failed", { detail: msg("git.merge.failed") });
const message = result.error ?? fallback;
const stashNote =
result.stashPreserved && !message.includes(stashPreservedMessage)
? `\n${stashPreservedMessage}`
: "";
toast.danger(message + stashNote);
return;
}
activeDialog.onComplete?.();
} catch (error) {
console.error("[git] stash pull from source failed", error);
toast.danger(friendlyError(error));
} finally {
setIsPulling(false);
}
}

const dialogContent = (
<>
<AlertDialog.Header className="gap-1">
<AlertDialog.Heading>Pull from {activeDialog.sourceBranch}?</AlertDialog.Heading>
<p className="text-sm leading-5 text-muted">
This worktree has local changes. Lightcode can temporarily stash them, pull from{" "}
{activeDialog.sourceBranch}, then re-apply your changes.
</p>
</AlertDialog.Header>
<AlertDialog.Footer>
<Button slot="close" variant="ghost" className="text-muted" isDisabled={isPulling}>
Cancel
</Button>
<Button variant="tertiary" onPress={handleStashPullReapply} isPending={isPulling}>
Stash & Pull
</Button>
</AlertDialog.Footer>
</>
);

return (
<AlertDialog.Backdrop isOpen onOpenChange={(open) => !open && handleClose()}>
<AlertDialog.Container size="sm">
<AlertDialog.Dialog className="sm:max-w-[420px] !p-4">{dialogContent}</AlertDialog.Dialog>
</AlertDialog.Container>
</AlertDialog.Backdrop>
);
}
4 changes: 4 additions & 0 deletions src/shared/contracts/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -419,12 +419,16 @@ export interface GitMergeToSourceResult {
export const gitPullFromSourcePayloadSchema = z.object({
worktreeLocation: projectLocationSchema,
sourceBranch: z.string().min(1),
preserveLocalChanges: z.boolean().default(false),
});
export type GitPullFromSourcePayload = z.infer<typeof gitPullFromSourcePayloadSchema>;

export interface GitPullFromSourceResult {
merged: boolean;
fastForward: boolean;
needsStash?: boolean;
reapplyConflicting?: boolean;
stashPreserved?: boolean;
conflicting?: boolean;
error?: string;
conflictFiles?: string[];
Expand Down
Binary file modified src/shared/messages.ts
Binary file not shown.
2 changes: 2 additions & 0 deletions src/supervisor/agents/acp/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import {
type AcpMapperState,
} from "./canonicalMapping";
import { terminateChildProcessTree } from "@/shared/processTree";
import { ensureNodePtySpawnHelperExecutable } from "@/supervisor/nodePty";
import {
buildPosixExportPrefix,
createKnownSessionRef,
Expand Down Expand Up @@ -1234,6 +1235,7 @@ export class AcpStructuredSession implements StructuredSessionHandle {
return { terminalId };
}

ensureNodePtySpawnHelperExecutable();
const pty = spawnPty(launch.command, launch.args, {
...(launch.cwd ? { cwd: launch.cwd } : {}),
env: launch.env,
Expand Down
51 changes: 51 additions & 0 deletions src/supervisor/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,57 @@ describe("GitService.pullFromSource", () => {
expect(result.conflicting).toBe(true);
expect(result.conflictFiles).toEqual(["src/file.ts"]);
});

it("asks the renderer to confirm stashing before pulling with local changes", async () => {
mockGitCommands((args) => {
if (args[0] === "status") return { stdout: " M src/file.ts\n" };
return { stdout: "" };
});

const result = await new GitService().pullFromSource(location, "main");

expect(result).toMatchObject({ merged: false, fastForward: false, needsStash: true });
expect(
execFileMock.mock.calls.some(
(c: unknown[]) => Array.isArray(c[1]) && (c[1] as string[])[0] === "merge",
),
).toBe(false);
});

it("stashes local changes, pulls from source, and reapplies the stash when requested", async () => {
mockGitCommands((args) => {
if (args[0] === "status") return { stdout: " M src/file.ts\n" };
return { stdout: "" };
});

const result = await new GitService().pullFromSource(location, "main", true);

expect(result).toEqual({ merged: true, fastForward: true });
const commands = execFileMock.mock.calls.map((c: unknown[]) => (c[1] as string[]).join(" "));
expect(commands).toContain("stash push -u -m Lightcode: before pull from main");
expect(commands).toContain("merge --ff-only main");
expect(commands).toContain("stash pop");
});

it("reports conflicts from re-applying stashed local changes", async () => {
mockGitCommands((args) => {
if (args[0] === "status") return { stdout: " M src/file.ts\n" };
if (args[0] === "stash" && args[1] === "pop") {
return { error: new Error("CONFLICT (content): Merge conflict in src/file.ts") };
}
return { stdout: "" };
});

const result = await new GitService().pullFromSource(location, "main", true);

expect(result).toMatchObject({
merged: false,
fastForward: true,
conflicting: true,
reapplyConflicting: true,
conflictFiles: ["src/file.ts"],
});
});
});

describe("GitService.mergeToSource (non-FF path)", () => {
Expand Down
3 changes: 2 additions & 1 deletion src/supervisor/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,8 +329,9 @@ export class GitService {
async pullFromSource(
worktreeLocation: ProjectLocation,
sourceBranch: string,
preserveLocalChanges = false,
): Promise<GitPullFromSourceResult> {
return this.mergeService.pullFromSource(worktreeLocation, sourceBranch);
return this.mergeService.pullFromSource(worktreeLocation, sourceBranch, preserveLocalChanges);
}

async abortMerge(worktreeLocation: ProjectLocation): Promise<void> {
Expand Down
Loading