Skip to content

Commit 3317ab5

Browse files
authored
feat(git): support stashing local changes on pull from source (#9)
- Add `PullFromSourceDialog` and state store to prompt for stash confirmation - Update git pull contracts, actions, and services to support the stashing flow - Stop appending `--delete-branch` in GitHub PR merge command - Extract and centralize the `node-pty` spawn-helper executable check
1 parent 5b2cbbb commit 3317ab5

17 files changed

Lines changed: 337 additions & 67 deletions

File tree

src/renderer/actions/gitActions.ts

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { readBridge } from "@/renderer/bridge";
33
import { captureRendererException } from "@/renderer/diagnostics/sentry";
44
import { useAppStore } from "@/renderer/state/appStore";
55
import { usePanelStore } from "@/renderer/state/panelStore";
6+
import { usePullFromSourceDialogStore } from "@/renderer/state/pullFromSourceDialogStore";
67
import { useSharedSettings } from "@/renderer/state/sharedSettingsStore";
78
import { resolveWorktreeBranch } from "@/renderer/utils/gitHelpers";
89
import { closeThreads } from "@/renderer/utils/shellUtils";
@@ -12,6 +13,17 @@ function captureGitActionError(error: unknown): void {
1213
captureRendererException(error, { featureArea: "git" });
1314
}
1415

16+
export function openGitReviewForWorktree(projectId: string, worktreePath: string): void {
17+
const mode = useSharedSettings.getState().gitReviewMode;
18+
const panelStore = usePanelStore.getState();
19+
panelStore.setGitReviewContext({ projectId, worktreePath });
20+
if (mode === "panel") {
21+
panelStore.setGitReviewAsPanel(true);
22+
} else {
23+
panelStore.setGitOverlayOpen(true);
24+
}
25+
}
26+
1527
export function gitSync(projectId: string, worktreePath?: string): void {
1628
const project = useAppStore.getState().projects.find((p) => p.id === projectId);
1729
if (!project) return;
@@ -141,16 +153,18 @@ export function gitPullFromSource(projectId: string, worktreePath: string): void
141153
const result = await readBridge().gitPullFromSource({
142154
worktreeLocation,
143155
sourceBranch,
156+
preserveLocalChanges: false,
144157
});
158+
if (result.needsStash) {
159+
usePullFromSourceDialogStore.getState().setDialog({
160+
projectId,
161+
worktreePath,
162+
sourceBranch,
163+
});
164+
return;
165+
}
145166
if (result.conflicting) {
146-
const mode = useSharedSettings.getState().gitReviewMode;
147-
const panelStore = usePanelStore.getState();
148-
panelStore.setGitReviewContext({ projectId, worktreePath });
149-
if (mode === "panel") {
150-
panelStore.setGitReviewAsPanel(true);
151-
} else {
152-
panelStore.setGitOverlayOpen(true);
153-
}
167+
openGitReviewForWorktree(projectId, worktreePath);
154168
}
155169
} catch (error) {
156170
captureGitActionError(error);
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { create } from "zustand";
2+
3+
export interface PullFromSourceDialogState {
4+
projectId: string;
5+
worktreePath: string;
6+
sourceBranch: string;
7+
onComplete?: () => void;
8+
}
9+
10+
interface PullFromSourceDialogStore {
11+
dialog: PullFromSourceDialogState | null;
12+
setDialog: (dialog: PullFromSourceDialogState) => void;
13+
closeDialog: () => void;
14+
}
15+
16+
export const usePullFromSourceDialogStore = create<PullFromSourceDialogStore>()((set) => ({
17+
dialog: null,
18+
setDialog: (dialog) => set({ dialog }),
19+
closeDialog: () => set({ dialog: null }),
20+
}));

src/renderer/styles.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
22
@import "@heroui/styles";
33
@import "@xterm/xterm/css/xterm.css";
44

5+
:root {
6+
--lightcode-modal-radius: 0.7rem;
7+
}
8+
9+
.modal__dialog:not(.modal__dialog--cover):not(.modal__dialog--full),
10+
.alert-dialog__dialog:not(.alert-dialog__dialog--cover) {
11+
border-radius: var(--lightcode-modal-radius);
12+
}
13+
514
@property --lc-chat-bottom-mask-end-alpha {
615
syntax: "<number>";
716
initial-value: 0;

src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/useGitReviewActions.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { readBridge } from "@/renderer/bridge";
88
import { captureProductEvent } from "@/renderer/analytics/posthog";
99
import { useAgentStatusesStore } from "@/renderer/state/agentStatusesStore";
1010
import { useGitStore } from "@/renderer/state/gitStore";
11+
import { usePullFromSourceDialogStore } from "@/renderer/state/pullFromSourceDialogStore";
1112
import { useSharedSettings } from "@/renderer/state/sharedSettingsStore";
1213
import {
1314
generateCommitMessageWithFallback,
@@ -357,7 +358,17 @@ export function useGitReviewActions(args: UseGitReviewActionsArgs) {
357358
const result = await readBridge().gitPullFromSource({
358359
worktreeLocation: getWorktreeLocation(),
359360
sourceBranch,
361+
preserveLocalChanges: false,
360362
});
363+
if (result.needsStash && worktreePath) {
364+
usePullFromSourceDialogStore.getState().setDialog({
365+
projectId: project.id,
366+
worktreePath,
367+
sourceBranch,
368+
onComplete: onRefresh,
369+
});
370+
return;
371+
}
361372
if (result.conflicting) {
362373
onRefresh();
363374
return;

src/renderer/views/MainView/MainView.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { useBrowserSync } from "@/renderer/views/MainView/parts/RightPanel/parts
1919

2020
import { AppOverlays } from "@/renderer/views/MainView/parts/AppOverlays";
2121
import { WorktreeDeleteDialogs } from "@/renderer/views/MainView/parts/WorktreeDeleteDialogs";
22+
import { PullFromSourceDialog } from "@/renderer/views/MainView/parts/PullFromSourceDialog";
2223
import { MainPageLayout, StalePanelCleanup } from "@/renderer/views/MainView/parts/MainPageLayout";
2324
import { ThreadSearchOverlayHost } from "@/renderer/views/ThreadSearchOverlay/ThreadSearchOverlay";
2425

@@ -113,6 +114,7 @@ export function MainView(props: { storeHydrated: boolean; loadT0: number }) {
113114
<StalePanelCleanup />
114115
<AppOverlays />
115116
<WorktreeDeleteDialogs />
117+
<PullFromSourceDialog />
116118
</>
117119
);
118120
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { useState } from "react";
2+
import { AlertDialog, toast } from "@heroui/react";
3+
import { buildWorktreeLocation } from "@/shared/worktree";
4+
import { friendlyError, msg } from "@/shared/messages";
5+
import { readBridge } from "@/renderer/bridge";
6+
import { openGitReviewForWorktree } from "@/renderer/actions/gitActions";
7+
import { Button } from "@/renderer/components/common/Button";
8+
import { useAppStore } from "@/renderer/state/appStore";
9+
import { usePullFromSourceDialogStore } from "@/renderer/state/pullFromSourceDialogStore";
10+
11+
export function PullFromSourceDialog() {
12+
const dialog = usePullFromSourceDialogStore((s) => s.dialog);
13+
const closeDialog = usePullFromSourceDialogStore((s) => s.closeDialog);
14+
const project = useAppStore((s) => s.projects.find((p) => p.id === dialog?.projectId));
15+
const [isPulling, setIsPulling] = useState(false);
16+
17+
if (!dialog || !project) return null;
18+
19+
const activeDialog = dialog;
20+
const activeProject = project;
21+
22+
function handleClose() {
23+
closeDialog();
24+
}
25+
26+
async function handleStashPullReapply() {
27+
setIsPulling(true);
28+
try {
29+
const stashPreservedMessage = msg("git.pull.stashPreserved");
30+
const result = await readBridge().gitPullFromSource({
31+
worktreeLocation: buildWorktreeLocation(activeProject.location, activeDialog.worktreePath),
32+
sourceBranch: activeDialog.sourceBranch,
33+
preserveLocalChanges: true,
34+
});
35+
closeDialog();
36+
if (result.conflicting) {
37+
const detail = result.conflictFiles?.length
38+
? `\nConflicts:\n${result.conflictFiles.join("\n")}`
39+
: "";
40+
const stashNote = result.stashPreserved ? `\n${stashPreservedMessage}` : "";
41+
toast.danger((result.error ?? msg("git.merge.conflicts")) + detail + stashNote);
42+
openGitReviewForWorktree(activeDialog.projectId, activeDialog.worktreePath);
43+
activeDialog.onComplete?.();
44+
return;
45+
}
46+
if (!result.merged) {
47+
const fallback = msg("git.pull.failed", { detail: msg("git.merge.failed") });
48+
const message = result.error ?? fallback;
49+
const stashNote =
50+
result.stashPreserved && !message.includes(stashPreservedMessage)
51+
? `\n${stashPreservedMessage}`
52+
: "";
53+
toast.danger(message + stashNote);
54+
return;
55+
}
56+
activeDialog.onComplete?.();
57+
} catch (error) {
58+
console.error("[git] stash pull from source failed", error);
59+
toast.danger(friendlyError(error));
60+
} finally {
61+
setIsPulling(false);
62+
}
63+
}
64+
65+
const dialogContent = (
66+
<>
67+
<AlertDialog.Header className="gap-1">
68+
<AlertDialog.Heading>Pull from {activeDialog.sourceBranch}?</AlertDialog.Heading>
69+
<p className="text-sm leading-5 text-muted">
70+
This worktree has local changes. Lightcode can temporarily stash them, pull from{" "}
71+
{activeDialog.sourceBranch}, then re-apply your changes.
72+
</p>
73+
</AlertDialog.Header>
74+
<AlertDialog.Footer>
75+
<Button slot="close" variant="ghost" className="text-muted" isDisabled={isPulling}>
76+
Cancel
77+
</Button>
78+
<Button variant="tertiary" onPress={handleStashPullReapply} isPending={isPulling}>
79+
Stash & Pull
80+
</Button>
81+
</AlertDialog.Footer>
82+
</>
83+
);
84+
85+
return (
86+
<AlertDialog.Backdrop isOpen onOpenChange={(open) => !open && handleClose()}>
87+
<AlertDialog.Container size="sm">
88+
<AlertDialog.Dialog className="sm:max-w-[420px] !p-4">{dialogContent}</AlertDialog.Dialog>
89+
</AlertDialog.Container>
90+
</AlertDialog.Backdrop>
91+
);
92+
}

src/shared/contracts/git.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,12 +419,16 @@ export interface GitMergeToSourceResult {
419419
export const gitPullFromSourcePayloadSchema = z.object({
420420
worktreeLocation: projectLocationSchema,
421421
sourceBranch: z.string().min(1),
422+
preserveLocalChanges: z.boolean().default(false),
422423
});
423424
export type GitPullFromSourcePayload = z.infer<typeof gitPullFromSourcePayloadSchema>;
424425

425426
export interface GitPullFromSourceResult {
426427
merged: boolean;
427428
fastForward: boolean;
429+
needsStash?: boolean;
430+
reapplyConflicting?: boolean;
431+
stashPreserved?: boolean;
428432
conflicting?: boolean;
429433
error?: string;
430434
conflictFiles?: string[];

src/shared/messages.ts

273 Bytes
Binary file not shown.

src/supervisor/agents/acp/session.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ import {
7171
type AcpMapperState,
7272
} from "./canonicalMapping";
7373
import { terminateChildProcessTree } from "@/shared/processTree";
74+
import { ensureNodePtySpawnHelperExecutable } from "@/supervisor/nodePty";
7475
import {
7576
buildPosixExportPrefix,
7677
createKnownSessionRef,
@@ -1234,6 +1235,7 @@ export class AcpStructuredSession implements StructuredSessionHandle {
12341235
return { terminalId };
12351236
}
12361237

1238+
ensureNodePtySpawnHelperExecutable();
12371239
const pty = spawnPty(launch.command, launch.args, {
12381240
...(launch.cwd ? { cwd: launch.cwd } : {}),
12391241
env: launch.env,

src/supervisor/git.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,57 @@ describe("GitService.pullFromSource", () => {
560560
expect(result.conflicting).toBe(true);
561561
expect(result.conflictFiles).toEqual(["src/file.ts"]);
562562
});
563+
564+
it("asks the renderer to confirm stashing before pulling with local changes", async () => {
565+
mockGitCommands((args) => {
566+
if (args[0] === "status") return { stdout: " M src/file.ts\n" };
567+
return { stdout: "" };
568+
});
569+
570+
const result = await new GitService().pullFromSource(location, "main");
571+
572+
expect(result).toMatchObject({ merged: false, fastForward: false, needsStash: true });
573+
expect(
574+
execFileMock.mock.calls.some(
575+
(c: unknown[]) => Array.isArray(c[1]) && (c[1] as string[])[0] === "merge",
576+
),
577+
).toBe(false);
578+
});
579+
580+
it("stashes local changes, pulls from source, and reapplies the stash when requested", async () => {
581+
mockGitCommands((args) => {
582+
if (args[0] === "status") return { stdout: " M src/file.ts\n" };
583+
return { stdout: "" };
584+
});
585+
586+
const result = await new GitService().pullFromSource(location, "main", true);
587+
588+
expect(result).toEqual({ merged: true, fastForward: true });
589+
const commands = execFileMock.mock.calls.map((c: unknown[]) => (c[1] as string[]).join(" "));
590+
expect(commands).toContain("stash push -u -m Lightcode: before pull from main");
591+
expect(commands).toContain("merge --ff-only main");
592+
expect(commands).toContain("stash pop");
593+
});
594+
595+
it("reports conflicts from re-applying stashed local changes", async () => {
596+
mockGitCommands((args) => {
597+
if (args[0] === "status") return { stdout: " M src/file.ts\n" };
598+
if (args[0] === "stash" && args[1] === "pop") {
599+
return { error: new Error("CONFLICT (content): Merge conflict in src/file.ts") };
600+
}
601+
return { stdout: "" };
602+
});
603+
604+
const result = await new GitService().pullFromSource(location, "main", true);
605+
606+
expect(result).toMatchObject({
607+
merged: false,
608+
fastForward: true,
609+
conflicting: true,
610+
reapplyConflicting: true,
611+
conflictFiles: ["src/file.ts"],
612+
});
613+
});
563614
});
564615

565616
describe("GitService.mergeToSource (non-FF path)", () => {

0 commit comments

Comments
 (0)