Skip to content

Commit 7c228ad

Browse files
committed
Add stack-level rebase context menu using workspace_integrate_upstream API
Wire up the new workspace_integrate_upstream API to a stack-level context menu item, allowing users to rebase a single stack onto its target branch. - Add RebaseStackModal that performs a dry-run preview showing how many commits will be rewritten, then executes the rebase on confirmation. - Add "Rebase onto target" option to the stack context menu. - Add workspaceIntegrateUpstream mutation endpoint and service accessor.
1 parent 9ee679e commit 7c228ad

4 files changed

Lines changed: 179 additions & 0 deletions

File tree

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
<script lang="ts" module>
2+
export type RebaseStackModalProps = {
3+
projectId: string;
4+
stackId: string | undefined;
5+
};
6+
</script>
7+
8+
<script lang="ts">
9+
import { parseQueryError } from "$lib/error/error";
10+
import { STACK_SERVICE } from "$lib/stacks/stackService.svelte";
11+
import { inject } from "@gitbutler/core/context";
12+
import { Button, Modal } from "@gitbutler/ui";
13+
import { tick } from "svelte";
14+
import type { BottomUpdate, WorkspaceState } from "@gitbutler/but-sdk";
15+
16+
const { projectId, stackId }: RebaseStackModalProps = $props();
17+
18+
const stackService = inject(STACK_SERVICE);
19+
const [integrateUpstream, integrateOp] = stackService.workspaceIntegrateUpstream;
20+
21+
let modal = $state<Modal>();
22+
let dryRunResult = $state<WorkspaceState | undefined>();
23+
let dryRunError = $state<string | undefined>();
24+
let bottomUpdates = $state<BottomUpdate[]>([]);
25+
26+
export async function show() {
27+
dryRunResult = undefined;
28+
dryRunError = undefined;
29+
bottomUpdates = [];
30+
31+
try {
32+
const branches = await stackService.fetchBranches(projectId, stackId!);
33+
if (!branches || branches.length === 0) {
34+
dryRunError = "No branches found in this stack.";
35+
await tick();
36+
modal?.show();
37+
return;
38+
}
39+
40+
// The bottom branch is the last in the array (ordered top to bottom).
41+
// Its bottom-most commit is the last in its commits array.
42+
const bottomBranch = branches[branches.length - 1]!;
43+
const bottomCommit = bottomBranch.commits[bottomBranch.commits.length - 1];
44+
45+
if (bottomCommit) {
46+
bottomUpdates = [
47+
{
48+
kind: "rebase",
49+
selector: { type: "commit", subject: bottomCommit.id },
50+
},
51+
];
52+
} else {
53+
// Empty branch — use the branch reference as selector
54+
bottomUpdates = [
55+
{
56+
kind: "rebase",
57+
selector: {
58+
type: "reference",
59+
subject: bottomBranch.reference,
60+
},
61+
},
62+
];
63+
}
64+
65+
const result = await integrateUpstream({
66+
projectId,
67+
updates: bottomUpdates,
68+
dryRun: true,
69+
});
70+
71+
dryRunResult = result;
72+
} catch (err: unknown) {
73+
dryRunError = parseQueryError(err).message;
74+
}
75+
76+
await tick();
77+
modal?.show();
78+
}
79+
80+
const replacedCount = $derived(
81+
dryRunResult ? Object.keys(dryRunResult.replacedCommits).length : 0,
82+
);
83+
</script>
84+
85+
<Modal
86+
bind:this={modal}
87+
width="small"
88+
title="Rebase stack"
89+
onSubmit={async (close) => {
90+
await integrateUpstream({
91+
projectId,
92+
updates: bottomUpdates,
93+
dryRun: false,
94+
});
95+
close();
96+
}}
97+
>
98+
{#if dryRunError}
99+
<p class="text-13 text-body">Failed to preview rebase:</p>
100+
<p class="text-13 text-body text-error">{dryRunError}</p>
101+
{:else if dryRunResult}
102+
<p class="text-13 text-body">This will rebase your stack onto the latest target branch.</p>
103+
{#if replacedCount > 0}
104+
<p class="text-13 text-body details">
105+
{replacedCount}
106+
{replacedCount === 1 ? "commit" : "commits"} will be rewritten.
107+
</p>
108+
{:else}
109+
<p class="text-13 text-body details">
110+
Your stack is already up to date. No commits will be rewritten.
111+
</p>
112+
{/if}
113+
{:else}
114+
<p class="text-13 text-body">Loading preview...</p>
115+
{/if}
116+
117+
{#snippet controls(close)}
118+
<Button kind="outline" onclick={close}>Cancel</Button>
119+
<Button
120+
style="pop"
121+
type="submit"
122+
disabled={!!dryRunError || !dryRunResult}
123+
loading={integrateOp.current.isLoading}
124+
>
125+
Rebase
126+
</Button>
127+
{/snippet}
128+
</Modal>
129+
130+
<style lang="postcss">
131+
.details {
132+
margin-top: 8px;
133+
color: var(--text-2);
134+
}
135+
136+
.text-error {
137+
color: var(--text-error);
138+
word-break: break-word;
139+
}
140+
</style>

apps/desktop/src/components/stack/StackDragHandle.svelte

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
<script lang="ts">
22
import CollapseStackButton from "$components/branch/CollapseStackButton.svelte";
3+
import RebaseStackModal from "$components/stack/RebaseStackModal.svelte";
34
import { IRC_SESSION_BRIDGE } from "$lib/irc/sessionBridge.svelte";
45
import { SETTINGS_SERVICE } from "$lib/settings/appSettings";
56
import { STACK_SERVICE } from "$lib/stacks/stackService.svelte";
67
import { inject } from "@gitbutler/core/context";
78
import { ContextMenuItem, ContextMenuSection, Icon, KebabButton } from "@gitbutler/ui";
9+
810
import type { Stack } from "$lib/stacks/stack";
911
1012
type Props = {
@@ -28,6 +30,8 @@
2830
);
2931
const isManuallyBridged = $derived(ircSessionBridge.isManuallyBridged(stackId));
3032
33+
let rebaseModal = $state<RebaseStackModal>();
34+
3135
function toggleBridging() {
3236
if (!stackId) return;
3337
ircSessionBridge.setManualBridge(stackId, !isManuallyBridged.current);
@@ -128,6 +132,15 @@
128132
/>
129133
</ContextMenuSection>
130134
<ContextMenuSection>
135+
<ContextMenuItem
136+
label="Rebase onto target"
137+
icon="refresh"
138+
disabled={!stackId}
139+
onclick={() => {
140+
close();
141+
rebaseModal?.show();
142+
}}
143+
/>
131144
<ContextMenuItem
132145
label="Unapply stack"
133146
icon="eject"
@@ -153,6 +166,10 @@
153166
</KebabButton>
154167
</div>
155168

169+
{#if stackId}
170+
<RebaseStackModal bind:this={rebaseModal} {projectId} {stackId} />
171+
{/if}
172+
156173
<style lang="postcss">
157174
.drag-handle-row {
158175
display: flex;

apps/desktop/src/lib/stacks/stackEndpoints.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ import type {
3939
UncommitResult,
4040
InsertSide,
4141
RelativeTo,
42+
BottomUpdate,
43+
WorkspaceState,
4244
} from "@gitbutler/but-sdk";
4345

4446
export type BranchParams = {
@@ -931,5 +933,21 @@ export function buildStackEndpoints(build: BackendEndpointBuilder) {
931933
extraOptions: { command: "pr_template" },
932934
query: (args) => args,
933935
}),
936+
workspaceIntegrateUpstream: build.mutation<
937+
WorkspaceState,
938+
{ projectId: string; updates: BottomUpdate[]; dryRun: boolean }
939+
>({
940+
extraOptions: {
941+
command: "workspace_integrate_upstream",
942+
actionName: "Integrate Upstream (Workspace)",
943+
},
944+
query: (args) => args,
945+
invalidatesTags: [
946+
invalidatesList(ReduxTag.HeadSha),
947+
invalidatesList(ReduxTag.WorktreeChanges),
948+
invalidatesList(ReduxTag.Stacks),
949+
invalidatesList(ReduxTag.UpstreamIntegrationStatus),
950+
],
951+
}),
934952
};
935953
}

apps/desktop/src/lib/stacks/stackService.svelte.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,10 @@ export class StackService {
578578
return this.backendApi.endpoints.unapply.mutate;
579579
}
580580

581+
get workspaceIntegrateUpstream() {
582+
return this.backendApi.endpoints.workspaceIntegrateUpstream.useMutation();
583+
}
584+
581585
get discardChanges() {
582586
return this.backendApi.endpoints.discardChanges.mutate;
583587
}

0 commit comments

Comments
 (0)