Skip to content

Commit 045a786

Browse files
committed
fix(resolve): add actionable recovery and targeted submodule-safe cleanup
1 parent bad49bc commit 045a786

File tree

22 files changed

+1201
-170
lines changed

22 files changed

+1201
-170
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/desktop/src/components/CommitFailedFileEntry.svelte

Lines changed: 192 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
<script lang="ts">
2+
import { goto } from "$app/navigation";
23
import ReduxResult from "$components/ReduxResult.svelte";
4+
import { showError, showToast } from "$lib/notifications/toasts";
5+
import { branchesPath } from "$lib/routes/routes.svelte";
6+
import { handleCreateBranchFromBranchOutcome } from "$lib/stacks/stack";
37
import { DEPENDENCY_SERVICE } from "$lib/dependencies/dependencyService.svelte";
48
import { SETTINGS } from "$lib/settings/userSettings";
59
import { type RejectionReason } from "$lib/stacks/stackService.svelte";
610
import { STACK_SERVICE } from "$lib/stacks/stackService.svelte";
711
import { inject } from "@gitbutler/core/context";
8-
import { FileName, HunkDiff, Icon, Tooltip } from "@gitbutler/ui";
12+
import { AsyncButton, Button, FileName, HunkDiff, Icon, Tooltip } from "@gitbutler/ui";
913
1014
type Props = {
1115
path: string;
@@ -20,9 +24,109 @@
2024
const dependencyService = inject(DEPENDENCY_SERVICE);
2125
2226
let isFolded = $state(true);
27+
let applyingStackId = $state<string | null>(null);
28+
let locatingCommitId = $state<string | null>(null);
29+
let ignoredLocks = $state<Record<string, true>>({});
2330
2431
function reasonRelatedToDependencyInfo(reason: RejectionReason): boolean {
25-
return reason === "cherryPickMergeConflict" || reason === "workspaceMergeConflict";
32+
return (
33+
reason === "cherryPickMergeConflict" ||
34+
reason === "workspaceMergeConflict" ||
35+
reason === "workspaceMergeConflictOfUnrelatedFile"
36+
);
37+
}
38+
39+
function lockKey(lock: {
40+
commitId: string;
41+
target: { type: string; subject?: string };
42+
}): string {
43+
return `${lock.commitId}:${lock.target.type}:${lock.target.subject ?? ""}`;
44+
}
45+
46+
function ignoreLock(lock: {
47+
commitId: string;
48+
target: { type: string; subject?: string };
49+
}) {
50+
ignoredLocks = {
51+
...ignoredLocks,
52+
[lockKey(lock)]: true,
53+
};
54+
}
55+
56+
function isIgnoredLock(lock: {
57+
commitId: string;
58+
target: { type: string; subject?: string };
59+
}): boolean {
60+
return ignoredLocks[lockKey(lock)] === true;
61+
}
62+
63+
async function applyKnownButUnappliedStack(stackId: string, branchName: string) {
64+
applyingStackId = stackId;
65+
try {
66+
const outcome = await stackService.createVirtualBranchFromBranch({
67+
projectId,
68+
branch: `refs/heads/${branchName}`,
69+
});
70+
handleCreateBranchFromBranchOutcome(outcome);
71+
showToast({
72+
title: "Stack applied to workspace",
73+
message: `Applied stack '${branchName}'. Retry the commit after conflicts are resolved.`,
74+
style: "info",
75+
});
76+
} catch (error) {
77+
showError("Failed to apply stack", error);
78+
} finally {
79+
applyingStackId = null;
80+
}
81+
}
82+
83+
async function recoverStackByCommit(commitId: string) {
84+
locatingCommitId = commitId;
85+
try {
86+
const allStacks = await stackService.fetchAllStacks(projectId);
87+
if (!allStacks || allStacks.length === 0) {
88+
showToast({
89+
title: "No stacks available to recover",
90+
message: "No stacks were found. Open Branches to create/apply a stack and retry.",
91+
style: "warning",
92+
});
93+
return;
94+
}
95+
96+
for (const stack of allStacks) {
97+
if (!stack.id) continue;
98+
const branches = await stackService.fetchBranches(projectId, stack.id);
99+
const commitFound = branches?.some((branch) =>
100+
branch.commits.some((commit) => commit.id === commitId),
101+
);
102+
if (!commitFound) continue;
103+
104+
const stackHead = stack.heads.at(0)?.name;
105+
if (!stackHead) {
106+
showToast({
107+
title: "Stack found but no branch head",
108+
message: `Commit ${commitId.substring(0, 7)} belongs to a stack without a visible head branch.
109+
Open Branches to inspect and recover manually.`,
110+
style: "warning",
111+
});
112+
return;
113+
}
114+
115+
await applyKnownButUnappliedStack(stack.id, stackHead);
116+
return;
117+
}
118+
119+
showToast({
120+
title: "Commit could not be mapped to a stack",
121+
message: `Commit ${commitId.substring(0, 7)} was not found in known stacks.
122+
It may be orphaned history. Open Branches to create a recovery stack/branch.`,
123+
style: "warning",
124+
});
125+
} catch (error) {
126+
showError("Failed to recover unknown stack", error);
127+
} finally {
128+
locatingCommitId = null;
129+
}
26130
}
27131
</script>
28132

@@ -77,37 +181,86 @@
77181
</div>
78182
<div class="commit-failed__file-entry__dependency-locks__content">
79183
{#each dependency.locks as lock}
80-
{#if lock.target.type === "stack"}
81-
{@const branchesQuery = stackService.branches(projectId, lock.target.subject)}
82-
{@const branch = branchesQuery.response}
83-
{@const commitBranch = branch?.find((b) =>
84-
b.commits.some((c) => c.id === lock.commitId),
85-
)}
86-
{@const branchName = commitBranch?.name || "Unknown branch"}
87-
{@const commitMessage = commitBranch?.commits.find(
88-
(c) => c.id === lock.commitId,
89-
)}
90-
{@const commitTitle =
91-
commitMessage?.message.split("\n")[0] || "No commit message provided"}
92-
<p class="text-body commit-failed__file-entry-dependency-lock">
93-
<i class="commit-failed__text-icon"><Icon name="branch-small" /></i>
94-
<span class="text-semibold">{branchName}</span>
95-
<i class="clr-text-2">in commit</i>
96-
<i class="commit-failed__text-icon"><Icon name="commit" /></i>
97-
<Tooltip text={commitTitle}>
98-
<span class="commit-failed__tooltip-text text-semibold h-dotted-underline"
99-
>{lock.commitId.substring(0, 7)}</span
184+
{#if !isIgnoredLock(lock)}
185+
{#if lock.target.type === "stack"}
186+
{@const stackTarget = lock.target as { type: "stack"; subject: string }}
187+
{@const branchesQuery = stackService.branches(projectId, stackTarget.subject)}
188+
{@const branch = branchesQuery.response}
189+
{@const commitBranch = branch?.find((b) =>
190+
b.commits.some((c) => c.id === lock.commitId),
191+
)}
192+
{@const knownStack = stackService.allStackById(projectId, stackTarget.subject).response}
193+
{@const stackHeadBranch = knownStack?.heads.at(0)?.name}
194+
{@const branchName = commitBranch?.name || knownStack?.heads.at(0)?.name || "Unknown stack"}
195+
{@const commitMessage = commitBranch?.commits.find(
196+
(c) => c.id === lock.commitId,
197+
)}
198+
{@const commitTitle =
199+
commitMessage?.message.split("\n")[0] || "No commit message provided"}
200+
<p class="text-body commit-failed__file-entry-dependency-lock">
201+
<i class="commit-failed__text-icon"><Icon name="branch-small" /></i>
202+
<span class="text-semibold">{branchName}</span>
203+
<i class="clr-text-2">in commit</i>
204+
<i class="commit-failed__text-icon"><Icon name="commit" /></i>
205+
<Tooltip text={commitTitle}>
206+
<span class="commit-failed__tooltip-text text-semibold h-dotted-underline">{lock.commitId.substring(0, 7)}</span>
207+
</Tooltip>
208+
</p>
209+
210+
{#if !commitBranch && knownStack && stackHeadBranch}
211+
<p class="text-12 clr-text-2">
212+
This stack exists but is not currently applied in your workspace.
213+
</p>
214+
<div class="commit-failed__lock-actions">
215+
<AsyncButton
216+
kind="outline"
217+
loading={applyingStackId === stackTarget.subject}
218+
action={async () => await applyKnownButUnappliedStack(stackTarget.subject, stackHeadBranch)}
219+
>
220+
Apply stack to workspace
221+
</AsyncButton>
222+
<Button kind="outline" onclick={() => goto(branchesPath(projectId))}>Open Branches</Button>
223+
<Button kind="ghost" onclick={() => ignoreLock(lock)}>Ignore for now</Button>
224+
</div>
225+
{:else if !commitBranch && !knownStack}
226+
<p class="text-12 clr-text-2">
227+
The stack id is no longer known. This can happen when the source stack was removed or rewritten.
228+
</p>
229+
<div class="commit-failed__lock-actions">
230+
<AsyncButton
231+
kind="outline"
232+
loading={locatingCommitId === lock.commitId}
233+
action={async () => await recoverStackByCommit(lock.commitId)}
234+
>
235+
Try recover by commit id
236+
</AsyncButton>
237+
<Button kind="outline" onclick={() => goto(branchesPath(projectId))}>Open Branches</Button>
238+
<Button kind="ghost" onclick={() => ignoreLock(lock)}>Ignore for now</Button>
239+
</div>
240+
{/if}
241+
{:else}
242+
<p class="text-body commit-failed__file-entry-dependency-lock">
243+
<i class="commit-failed__text-icon"><Icon name="branch-small" /></i>
244+
<span class="text-semibold">Unknown stack</span>
245+
<i class="clr-text-2">in commit</i>
246+
<i class="commit-failed__text-icon"><Icon name="commit" /></i>
247+
<span class="text-semibold">{lock.commitId.substring(0, 7)}</span>
248+
</p>
249+
<p class="text-12 clr-text-2">
250+
The dependency solver could not map this lock to a stack id in the current workspace.
251+
</p>
252+
<div class="commit-failed__lock-actions">
253+
<AsyncButton
254+
kind="outline"
255+
loading={locatingCommitId === lock.commitId}
256+
action={async () => await recoverStackByCommit(lock.commitId)}
100257
>
101-
</Tooltip>
102-
</p>
103-
{:else}
104-
<p class="text-body commit-failed__file-entry-dependency-lock">
105-
<i class="commit-failed__text-icon"><Icon name="branch-small" /></i>
106-
<span class="text-semibold">Unknown stack</span>
107-
<i class="clr-text-2">in commit</i>
108-
<i class="commit-failed__text-icon"><Icon name="commit" /></i>
109-
<span class="text-semibold">{lock.commitId.substring(0, 7)}</span>
110-
</p>
258+
Try recover by commit id
259+
</AsyncButton>
260+
<Button kind="outline" onclick={() => goto(branchesPath(projectId))}>Open Branches</Button>
261+
<Button kind="ghost" onclick={() => ignoreLock(lock)}>Ignore for now</Button>
262+
</div>
263+
{/if}
111264
{/if}
112265
{/each}
113266
</div>
@@ -207,6 +360,13 @@
207360
gap: 4px;
208361
}
209362
363+
.commit-failed__lock-actions {
364+
display: flex;
365+
flex-wrap: wrap;
366+
gap: 6px;
367+
padding-top: 2px;
368+
}
369+
210370
/* MODIFIERS */
211371
.clickable {
212372
cursor: pointer;

apps/desktop/src/components/CommitFailedModalContent.svelte

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
<script lang="ts">
2+
import { goto } from "$app/navigation";
23
import CommitFailedFileEntry from "$components/CommitFailedFileEntry.svelte";
34
import ConfigurableScrollableContainer from "$components/ConfigurableScrollableContainer.svelte";
5+
import { branchesPath } from "$lib/routes/routes.svelte";
46
import { REJECTTION_REASONS, type RejectionReason } from "$lib/stacks/stackService.svelte";
5-
import { Icon, ModalHeader, TestId, Tooltip } from "@gitbutler/ui";
7+
import { Button, Icon, ModalHeader, TestId, Tooltip } from "@gitbutler/ui";
68
import type { CommitFailedModalState } from "$lib/state/uiState.svelte";
79
810
type Props = {
@@ -20,6 +22,8 @@
2022
2123
function getReadableRejectionReason(reason: RejectionReason): string {
2224
switch (reason) {
25+
case "workspaceMergeConflictOfUnrelatedFile":
26+
return "Workspace merge conflict (unrelated file)";
2327
case "cherryPickMergeConflict":
2428
return "Cherry-pick merge conflict";
2529
case "noEffectiveChanges":
@@ -38,6 +42,8 @@
3842
return "Unsupported tree entry";
3943
case "missingDiffSpecAssociation":
4044
return "Missing diff spec association";
45+
default:
46+
return "Unknown rejection reason";
4147
}
4248
}
4349
@@ -62,6 +68,19 @@
6268
}
6369
6470
const groupedData = groupByReason(data);
71+
const hasConflictReason = groupedData.some(({ reason }) =>
72+
reason === "workspaceMergeConflict" ||
73+
reason === "workspaceMergeConflictOfUnrelatedFile" ||
74+
reason === "cherryPickMergeConflict",
75+
);
76+
const requiredSteps =
77+
data.requiredSteps.length > 0
78+
? data.requiredSteps
79+
: [
80+
"Review rejected files and lock details listed below.",
81+
"Recover/apply missing stacks from lock actions, or open Branches to recreate missing stack context.",
82+
"Retry the commit once dependency and conflict blockers are cleared.",
83+
];
6584
6685
let isScrollTopVisible = $state(true);
6786
</script>
@@ -108,6 +127,26 @@
108127
</div>
109128
{/each}
110129
</div>
130+
131+
<div class="commit-failed__steps">
132+
<hr class="commit-failed__reasons-divider" />
133+
<p class="text-13">Required steps to resolve:</p>
134+
<ol class="commit-failed__steps-list text-13">
135+
{#each requiredSteps as step (step)}
136+
<li>{step}</li>
137+
{/each}
138+
</ol>
139+
{#if hasConflictReason}
140+
<p class="text-12 clr-text-2">
141+
Some locks reference commits outside the active workspace. Use the lock actions above to recover
142+
or re-associate stack context before retrying.
143+
</p>
144+
{/if}
145+
<div class="commit-failed__actions">
146+
<Button kind="outline" onclick={() => goto(branchesPath(data.projectId))}>Open Branches</Button>
147+
<Button kind="ghost" onclick={() => oncloseclick?.()}>Ignore for now</Button>
148+
</div>
149+
</div>
111150
</div>
112151
</ConfigurableScrollableContainer>
113152
</div>
@@ -152,4 +191,23 @@
152191
flex-direction: column;
153192
gap: 8px;
154193
}
194+
195+
.commit-failed__steps {
196+
display: flex;
197+
flex-direction: column;
198+
gap: 10px;
199+
}
200+
201+
.commit-failed__steps-list {
202+
margin: 0;
203+
padding-left: 20px;
204+
display: flex;
205+
flex-direction: column;
206+
gap: 6px;
207+
}
208+
209+
.commit-failed__actions {
210+
display: flex;
211+
gap: 8px;
212+
}
155213
</style>

apps/desktop/src/components/NewCommitView.svelte

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@
153153
},
154154
{},
155155
);
156+
const requiredSteps = response.requiredSteps;
156157
157158
uiState.global.modal.set({
158159
type: "commit-failed",
@@ -161,6 +162,7 @@
161162
newCommitId: newId ?? undefined,
162163
commitTitle: laneState.newCommitMessage.current?.title || "",
163164
pathsToRejectedChanges,
165+
requiredSteps,
164166
});
165167
}
166168
} finally {

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export default class StackMacros {
7676
},
7777
{},
7878
);
79+
const requiredSteps = outcome.requiredSteps;
7980

8081
this.uiState.global.modal.set({
8182
type: "commit-failed",
@@ -84,6 +85,7 @@ export default class StackMacros {
8485
newCommitId: outcome.newCommit ?? undefined,
8586
commitTitle: message ?? STUB_COMMIT_MESSAGE,
8687
pathsToRejectedChanges,
88+
requiredSteps,
8789
});
8890
}
8991
return { stack, outcome, branchName };

0 commit comments

Comments
 (0)