diff --git a/apps/desktop/src/components/diff/ConflictHunkCard.svelte b/apps/desktop/src/components/diff/ConflictHunkCard.svelte new file mode 100644 index 00000000000..cc16a7b13e7 --- /dev/null +++ b/apps/desktop/src/components/diff/ConflictHunkCard.svelte @@ -0,0 +1,271 @@ + + + + +
+
+ + + Unresolved conflict{total > 1 ? ` ${index + 1} of ${total}` : ""} + +
+
+
Current base
+
{hunk.ours}
+
+ {#if hunk.base !== null} +
+
Common ancestor
+
{hunk.base}
+
+ {/if} +
+
This commit
+
{hunk.theirs}
+
+ {#if commitId && path} + {#if editing} +
+
+ Edit the merged result that replaces this conflict. Leaving it empty deletes the region. +
+ + +
+ + +
+
+ {:else} +
+ + + + {#if $aiGenEnabled} + + {/if} +
+ {/if} + {/if} +
+ + diff --git a/apps/desktop/src/components/diff/MultiDiffView.svelte b/apps/desktop/src/components/diff/MultiDiffView.svelte index 7e9d7e61937..305f10a7953 100644 --- a/apps/desktop/src/components/diff/MultiDiffView.svelte +++ b/apps/desktop/src/components/diff/MultiDiffView.svelte @@ -24,7 +24,7 @@ import { inject } from "@gitbutler/core/context"; import { Button, FileViewHeader, HunkDiffSkeleton, VirtualList } from "@gitbutler/ui"; import { untrack } from "svelte"; - import type { TreeChange } from "@gitbutler/but-sdk"; + import type { ConflictedFile, TreeChange } from "@gitbutler/but-sdk"; type Props = { projectId: string; @@ -36,6 +36,8 @@ showRoundedEdges?: boolean; startIndex?: number; selectionId: SelectionId; + /// The selected commit's unresolved conflicts, shown inline per file. + conflicts?: ConflictedFile[]; onclose?: () => void; onVisibleChange?: (change: { start: number; end: number } | undefined) => void; }; @@ -50,10 +52,21 @@ showRoundedEdges = true, startIndex, selectionId, + conflicts, onclose, onVisibleChange, }: Props = $props(); + // Conflicted files whose diff is hidden by the auto-resolution get their + // synthetic base-vs-commit change appended, after `changes` so the indices + // callers use for jumping and selection stay stable. + const items: TreeChange[] = $derived([ + ...changes, + ...(conflicts ?? []) + .filter((file) => !changes.some((change) => change.path === file.path)) + .map((file) => file.change), + ]); + const diffService = inject(DIFF_SERVICE); const idSelection = inject(FILE_SELECTION_MANAGER); const uiState = inject(UI_STATE); @@ -87,6 +100,14 @@ } } + // Jump by path; this component owns the true render order of `items` + // (changes followed by appended conflict-only entries), so callers don't + // have to re-derive it. + export function jumpToPath(path: string) { + const index = items.findIndex((item) => item.path === path); + if (index >= 0) jumpToIndex(index); + } + export function openFloatingDiff() { floatingDiffInitialIndex = highlightedIndex ?? startIndex ?? 0; floatingDiffOpen = true; @@ -110,6 +131,7 @@ {@const diffQuery = diffService.getDiff(projectId, change)} {@const diffData = diffQuery.response} {@const isExecutable = isExecutableStatus(change.status)} + {@const conflictHunks = conflicts?.find((file) => file.path === change.path)?.hunks} {@const patchData = diffData?.type === "Patch" ? diffData.subject : null} {@const isCollapsed = diffExpandedState.get(change.path) ?? false} {/snippet} @@ -198,7 +222,7 @@ {/if} - {#if changes && changes.length > 0} + {#if items.length > 0} {#if !allInOneDiff} - {@const index = highlightedIndex ?? startIndex ?? 0} - {@const change = changes[index]} + {@const index = Math.min(highlightedIndex ?? startIndex ?? 0, items.length - 1)} + {@const change = items[index]} {#if change}
{@render changeItem(change, index)} @@ -221,7 +245,7 @@ bind:this={virtualList} {startIndex} grow - items={changes} + {items} defaultHeight={173} visibility="scroll" renderDistance={100} @@ -231,7 +255,7 @@ const activeIndex = scrollLock.resolve(range); highlightedIndex = activeIndex; - const activeChange = changes[activeIndex]; + const activeChange = items[activeIndex]; const selectionSize = idSelection.collectionSize(selectionId); const shouldFollowScrollSelection = selectionSize <= 1; if ( @@ -244,10 +268,10 @@ } onVisibleChange?.(range); }} - getId={(change) => change.path} + getId={(item) => item.path} > - {#snippet template(change, index)} - {@render changeItem(change, index, true)} + {#snippet template(item, index)} + {@render changeItem(item, index, true)} {/snippet} {/if} @@ -260,7 +284,7 @@ file.path === selectedFile?.path)?.hunks, + );
@@ -86,6 +101,7 @@ {diff} {selectable} selectionId={selectedFile} + conflictHunks={selectedFileConflicts} topPadding={diffOnly} />
diff --git a/apps/desktop/src/components/diff/UnifiedDiffView.svelte b/apps/desktop/src/components/diff/UnifiedDiffView.svelte index e92db6a0fdd..c0ec5b34025 100644 --- a/apps/desktop/src/components/diff/UnifiedDiffView.svelte +++ b/apps/desktop/src/components/diff/UnifiedDiffView.svelte @@ -1,4 +1,5 @@ +{#snippet conflictCards(bucket: { hunk: ConflictHunk; index: number }[])} + {#each bucket as conflict (conflict.index)} + + {/each} +{/snippet} + {#if fileDependenciesQuery} {:else} @@ -208,7 +257,27 @@
{:else if diff.type === "Patch"} {@const linesModified = diff.subject.linesAdded + diff.subject.linesRemoved} + {#if (conflictHunks?.length ?? 0) > 0} +
+ + {conflictHunks!.length} unresolved conflict{conflictHunks!.length === 1 ? "" : "s"} in this + file + + +
+ {/if} {#if linesModified > LARGE_DIFF_THRESHOLD && !showAnyways} + {#if showConflicts && conflictHunks} + {@render conflictCards(conflictHunks.map((hunk, index) => ({ hunk, index })))} + {/if} { showAnyways = true; @@ -219,6 +288,7 @@ {@const selection = uncommittedService.hunkCheckStatus(stackId, change.path, hunk)} {@const [_, lineLocks] = getLineLocks(hunk, fileDependencies?.dependencies ?? [])} {@const hunkId = generateHunkId(change.path, hunkIndex)} + {@render conflictCards(conflictBuckets[hunkIndex] ?? [])}
{:else} - {#if diff.subject.hunks.length === 0} + {#if showConflicts} + {@render conflictCards(conflictBuckets[0] ?? [])} + {:else if diff.subject.hunks.length === 0}
{#snippet caption()} @@ -334,6 +406,9 @@
{/if} {/each} + {#if filteredHunks.length > 0 && renderedHunkCount >= filteredHunks.length} + {@render conflictCards(conflictBuckets[filteredHunks.length] ?? [])} + {/if} {/if} {:else if diff.type === "TooLarge"}
@@ -391,6 +466,18 @@ border-radius: var(--radius-m); } + .conflicts-notice { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 6px 6px 10px; + gap: 8px; + border: 1px solid var(--border-2); + border-radius: var(--radius-m); + background-color: var(--bg-warn); + color: var(--text-warn); + } + .hunk-content { user-select: text; } diff --git a/apps/desktop/src/components/files/ChangedFilesPanel.svelte b/apps/desktop/src/components/files/ChangedFilesPanel.svelte index ad6d7b95b22..2b4eaea4640 100644 --- a/apps/desktop/src/components/files/ChangedFilesPanel.svelte +++ b/apps/desktop/src/components/files/ChangedFilesPanel.svelte @@ -25,6 +25,7 @@ autoselect?: boolean; ancestorMostConflictedCommitId?: string; onFileClick?: (index: number) => void; + onConflictedFileClick?: (path: string) => void; allowUnselect?: boolean; persistId?: string; foldedByDefault?: boolean; @@ -43,6 +44,7 @@ autoselect, ancestorMostConflictedCommitId, onFileClick, + onConflictedFileClick, allowUnselect = true, persistId = "default", foldedByDefault = false, @@ -126,6 +128,7 @@ {conflictEntries} {ancestorMostConflictedCommitId} draggable={draggableFiles} + onFileClick={onConflictedFileClick} /> void; }; - const { projectId, stackId, conflictEntries, ancestorMostConflictedCommitId, draggable }: Props = - $props(); + const { + projectId, + stackId, + conflictEntries, + ancestorMostConflictedCommitId, + draggable, + onFileClick, + }: Props = $props(); const controller = getFileListContext(); const modeService = injectOptional(MODE_SERVICE, undefined); @@ -89,7 +98,11 @@ isLast={!ancestorMostConflictedCommitId && i === entries.length - 1} onclick={(e) => { e.stopPropagation(); - showEditPatchConfirmation(path); + if (onFileClick) { + onFileClick(path); + } else { + showEditPatchConfirmation(path); + } }} /> {/each} diff --git a/apps/desktop/src/components/views/BranchCommitList.svelte b/apps/desktop/src/components/views/BranchCommitList.svelte index 4598e42968e..b38bee177e9 100644 --- a/apps/desktop/src/components/views/BranchCommitList.svelte +++ b/apps/desktop/src/components/views/BranchCommitList.svelte @@ -458,10 +458,7 @@ draggableFiles selectionId={createCommitSelection({ commitId: commitId, stackId })} persistId={`commit-${commitId}`} - changes={changesResult.changes.filter( - (change) => - !(change.path in (changesResult.conflictEntries?.entries ?? {})), - )} + changes={changesResult.changes} stats={changesResult.stats ?? undefined} conflictEntries={changesResult.conflictEntries} ancestorMostConflictedCommitId={firstConflictedCommitId} @@ -483,6 +480,15 @@ } controller.jumpToIndex(index); }} + onConflictedFileClick={(path) => { + controller.selection.set({ + branchName, + commitId, + upstream: false, + previewOpen: true, + }); + controller.jumpToPath(path); + }} /> {/snippet} diff --git a/apps/desktop/src/components/views/StackDetails.svelte b/apps/desktop/src/components/views/StackDetails.svelte index 69afeb56a39..ab6b1b87e3d 100644 --- a/apps/desktop/src/components/views/StackDetails.svelte +++ b/apps/desktop/src/components/views/StackDetails.svelte @@ -120,6 +120,7 @@ if (multiDiffView) { controller.registerDiffView({ jump: (index) => multiDiffView?.jumpToIndex(index), + jumpToPath: (path) => multiDiffView?.jumpToPath(path), popout: () => multiDiffView?.openFloatingDiff(), }); } @@ -199,12 +200,20 @@ {#if commitResult} {#snippet children(commit)} + {@const isConflicted = + !!commit.conflictEntries && + Object.keys(commit.conflictEntries.entries).length > 0} + {@const conflictsQuery = + isConflicted && commitId + ? stackService.commitConflicts(projectId, commitId) + : undefined} (STACK_CTX); +} + export class StackController { private uiState; private fileSelection: FileSelectionManager; @@ -49,6 +54,7 @@ export class StackController { visibleRange = $state<{ start: number; end: number } | undefined>(); private diffJumpHandler?: (index: number) => void; + private diffJumpPathHandler?: (path: string) => void; private diffPopoutHandler?: () => void; private _focusedFile = $state(); @@ -247,13 +253,19 @@ export class StackController { }); } - registerDiffView(handlers: { jump: (index: number) => void; popout: () => void }): void { + registerDiffView(handlers: { + jump: (index: number) => void; + jumpToPath: (path: string) => void; + popout: () => void; + }): void { this.diffJumpHandler = handlers.jump; + this.diffJumpPathHandler = handlers.jumpToPath; this.diffPopoutHandler = handlers.popout; } unregisterDiffView(): void { this.diffJumpHandler = undefined; + this.diffJumpPathHandler = undefined; this.diffPopoutHandler = undefined; } @@ -261,6 +273,10 @@ export class StackController { this.diffJumpHandler?.(index); } + jumpToPath(path: string): void { + this.diffJumpPathHandler?.(path); + } + openFloatingDiff(): void { this.diffPopoutHandler?.(); } diff --git a/apps/desktop/src/lib/stacks/stackEndpoints.ts b/apps/desktop/src/lib/stacks/stackEndpoints.ts index 66dc09c4a2d..ae1d806ccef 100644 --- a/apps/desktop/src/lib/stacks/stackEndpoints.ts +++ b/apps/desktop/src/lib/stacks/stackEndpoints.ts @@ -22,6 +22,9 @@ import type { AbsorptionTarget, AiResolutionResult, BranchLandResult, + CommitConflicts, + HunkResolutionResult, + ResolutionSpec, CommitAbsorption, BranchDetails, BranchReference, @@ -421,6 +424,16 @@ export function buildStackEndpoints(build: BackendEndpointBuilder) { ...(stackId ? [invalidatesItem(ReduxTag.StackDetails, stackId)] : []), ], }), + commitConflicts: build.query({ + // Conflicts are derived from the commit's own trees, so results are + // immutable per commit id. + keepUnusedDataFor: 60, + extraOptions: { command: "commit_conflicts" }, + query: (args) => args, + providesTags: (_result, _error, { commitId }) => [ + ...providesItem(ReduxTag.CommitChanges, commitId), + ], + }), resolveCommitConflictsAi: build.mutation< AiResolutionResult, { projectId: string; stackId?: string; commitId: string } @@ -441,6 +454,26 @@ export function buildStackEndpoints(build: BackendEndpointBuilder) { ...(stackId ? [invalidatesItem(ReduxTag.StackDetails, stackId)] : []), ], }), + resolveCommitConflictHunks: build.mutation< + HunkResolutionResult, + { projectId: string; stackId?: string; commitId: string; specs: ResolutionSpec[] } + >({ + extraOptions: { + command: "resolve_commit_conflict_hunks", + actionName: "Resolve Conflict", + }, + query: ({ projectId, commitId, specs }) => ({ + projectId, + commitId, + specs, + }), + invalidatesTags: (_result, _error, { stackId }) => [ + invalidatesList(ReduxTag.HeadSha), + invalidatesList(ReduxTag.BranchChanges), + invalidatesList(ReduxTag.WorktreeChanges), + ...(stackId ? [invalidatesItem(ReduxTag.StackDetails, stackId)] : []), + ], + }), newBranch: build.mutation< void, { projectId: string; stackId: string; request: { targetPatch?: string; name: string } } diff --git a/apps/desktop/src/lib/stacks/stackService.svelte.ts b/apps/desktop/src/lib/stacks/stackService.svelte.ts index 43ca64be507..8f4572be405 100644 --- a/apps/desktop/src/lib/stacks/stackService.svelte.ts +++ b/apps/desktop/src/lib/stacks/stackService.svelte.ts @@ -547,6 +547,14 @@ export class StackService { return this.backendApi.endpoints.resolveCommitConflictsAi.useMutation(); } + get resolveCommitConflictHunks() { + return this.backendApi.endpoints.resolveCommitConflictHunks.useMutation(); + } + + commitConflicts(projectId: string, commitId: string) { + return this.backendApi.endpoints.commitConflicts.useQuery({ projectId, commitId }); + } + get newBranch() { return this.backendApi.endpoints.newBranch.useMutation(); } diff --git a/apps/lite/electron/src/ipc.ts b/apps/lite/electron/src/ipc.ts index d6163094795..6e9aa940175 100644 --- a/apps/lite/electron/src/ipc.ts +++ b/apps/lite/electron/src/ipc.ts @@ -58,6 +58,7 @@ export const exposedEndpoints = [ "commentUpdate", "commentsList", "commitAmend", + "commitConflicts", "commitCreate", "commitDetailsWithLineStats", "commitDiscard", @@ -125,6 +126,7 @@ export const exposedEndpoints = [ "removeReviewLabel", "removeReviewReaction", "requestReview", + "resolveCommitConflictHunks", "restoreSnapshotWithKind", "reviewTemplate", "setGbConfig", diff --git a/apps/lite/ui/src/api/mutations.ts b/apps/lite/ui/src/api/mutations.ts index fb1aca7f1e3..32e2098742d 100644 --- a/apps/lite/ui/src/api/mutations.ts +++ b/apps/lite/ui/src/api/mutations.ts @@ -1356,6 +1356,51 @@ export const useCommitReword = () => { }); }; +/** + * Resolve some of a conflicted commit's conflicts. Every apply rewrites the + * commit, so the reply carries the replaced ids that `syncCoreCaches` feeds to + * the store — selection and checked operands follow the new commit on their own, + * and the conflicts query re-reads under the new id. + */ +export const useResolveCommitConflictHunks = () => { + const dispatch = useAppDispatch(); + const toastManager = Toast.useToastManager(); + + return useMutation({ + mutationFn: window.lite.resolveCommitConflictHunks, + onSuccess: async (response, input, _context, mutation) => { + syncCoreCaches(mutation.client, dispatch, input.projectId, response); + // The conflicts that remain renumber, so a check made against the old + // numbering would address a different conflict from the one clicked. + dispatch(projectSlice.actions.clearCheckedConflicts({ projectId: input.projectId })); + + // A commit with a manual-only file left is still conflicted, however + // many hunks were resolved, so both lists must be empty to be done. + if (response.remaining.length === 0 && response.manual.length === 0) { + toastManager.add({ + type: "success", + title: "All conflicts resolved", + description: response.commitEmptied + ? "The commit keeps nothing of its own now, so it no longer changes anything. Undo from the operations history if that wasn't the intent." + : "The commit is no longer conflicted.", + priority: "low", + }); + } + }, + onError: (error) => { + // oxlint-disable-next-line no-console + console.error(error); + + toastManager.add({ + type: "error", + title: "Failed to resolve the conflict", + description: errorMessageForToast(error), + priority: "high", + }); + }, + }); +}; + export const useCommitUncommit = () => { const dispatch = useAppDispatch(); const toastManager = Toast.useToastManager(); diff --git a/apps/lite/ui/src/api/queries.ts b/apps/lite/ui/src/api/queries.ts index 1277c3b712f..4a7dbe1d7c1 100644 --- a/apps/lite/ui/src/api/queries.ts +++ b/apps/lite/ui/src/api/queries.ts @@ -17,6 +17,7 @@ export type ProjectQueryKey = | "changesInWorktree" | "ciChecks" | "comments" + | "commitConflicts" | "commitDetailsWithLineStats" | "forgeInfo" | "headInfo" @@ -106,6 +107,29 @@ export const commitDetailsWithLineStatsQueryOptions = ({ queryFn: () => window.lite.commitDetailsWithLineStats({ projectId, ...params }), }); +/** + * A conflicted commit's conflicts, derived from the trees the commit itself + * carries — so the answer is immutable per commit id, and an apply that + * rewrites the commit lands on a different key rather than invalidating this + * one. Enable it only for commits already known to conflict: the backend + * answers for any commit, but the round-trip is pure cost otherwise. + */ +export const commitConflictsQueryOptions = ({ + projectId, + enabled, + ...params +}: PayloadFor<"commitConflicts"> & { enabled: boolean }) => + queryOptions({ + queryKey: ["commitConflicts" satisfies QueryKey, projectId, params], + queryFn: () => window.lite.commitConflicts({ projectId, ...params }), + enabled, + staleTime: Infinity, + // A commit whose conflicts have no hunk representation — a binary, a + // deletion, an oversized file — makes the backend reject the whole + // commit. That is a property of the commit, so retrying cannot help. + retry: false, + }); + export const forgeInfoOptions = (projectId: string) => queryOptions({ queryKey: ["forgeInfo" satisfies QueryKey, projectId], diff --git a/apps/lite/ui/src/projects/project.ts b/apps/lite/ui/src/projects/project.ts index ad023d69316..44729c15904 100644 --- a/apps/lite/ui/src/projects/project.ts +++ b/apps/lite/ui/src/projects/project.ts @@ -63,8 +63,19 @@ type CheckableOperand = Extract; export type BranchTab = "diff" | "pr"; +/** + * A conflict checked for a batch resolution. Keyed by commit as well as + * position: resolving rewrites the commit and renumbers the hunks that remain, + * so checks must never carry across an apply. + */ +type CheckedConflict = { commitId: string; path: string; hunk: number }; + +const conflictCheckKey = ({ commitId, path, hunk }: CheckedConflict): string => + `${commitId}\u0000${path}\u0000${hunk}`; + type WorkspaceState = { checkedOperands: Record; + checkedConflicts: Record; detailsSelectionScope: DetailsSelectionScope | null; /** * Branch segments whose commits are hidden, keyed by full ref name. @@ -109,6 +120,7 @@ const createInitialSelectionState = (): SelectionState => ({ const createInitialWorkspaceState = (): WorkspaceState => ({ checkedOperands: {}, + checkedConflicts: {}, detailsSelectionScope: null, foldedSegments: {}, highlightedCommitIds: [], @@ -386,6 +398,18 @@ export const projectReducers = { clearCheckedOperands: (state: ProjectState) => { state.workspace.checkedOperands = {}; }, + checkConflict: ( + state: ProjectState, + { conflict, checked }: { conflict: CheckedConflict; checked: boolean }, + ) => { + const key = conflictCheckKey(conflict); + if (checked) state.workspace.checkedConflicts[key] = conflict; + else delete state.workspace.checkedConflicts[key]; + }, + clearCheckedConflicts: (state: ProjectState) => { + if (Object.keys(state.workspace.checkedConflicts).length === 0) return; + state.workspace.checkedConflicts = {}; + }, updateRewrittenCommitReferences: ( state: ProjectState, { replacedCommits }: { replacedCommits: Record }, @@ -520,6 +544,14 @@ const selectCheckedOperands = createSelector( (checkedOperands): Array => Object.values(checkedOperands), ); +/** The checks belonging to `commitId`, so a different commit reads as none. */ +const selectCheckedConflictsFor = createSelector( + (state: ProjectState) => state.workspace.checkedConflicts, + (_state: ProjectState, commitId: string) => commitId, + (checkedConflicts, commitId): Array => + Object.values(checkedConflicts).filter((conflict) => conflict.commitId === commitId), +); + const selectCheckedOperandKeys = createSelector( (state: ProjectState) => state.workspace.checkedOperands, (checkedOperands): Set => new Set(Object.keys(checkedOperands)), @@ -614,6 +646,10 @@ export const projectSelectors = { state.workspace.selection.uncommittedFiles, (path) => path, ), + /** A primitive, so checking one conflict re-renders one card. */ + selectIsConflictChecked: (state: ProjectState, conflict: CheckedConflict): boolean => + conflictCheckKey(conflict) in state.workspace.checkedConflicts, + selectCheckedConflicts: selectCheckedConflictsFor, selectIsSelectedOutline: ( state: ProjectState, navigationIndex: NavigationIndex, diff --git a/apps/lite/ui/src/routes/project/$id/workspace/ConflictBar.module.css b/apps/lite/ui/src/routes/project/$id/workspace/ConflictBar.module.css new file mode 100644 index 00000000000..0d0dba40dd6 --- /dev/null +++ b/apps/lite/ui/src/routes/project/$id/workspace/ConflictBar.module.css @@ -0,0 +1,32 @@ +.bar { + display: flex; + align-items: center; + /* Fixed, so gaining the actions on the first check cannot resize the bar and + shift the diff under the pointer. */ + min-height: 44px; + padding: 0 12px; + gap: 10px; + border-bottom: 1px solid var(--border-2); + background: var(--bg-warn); + color: var(--text-warn); +} + +.status { + font-weight: bold; +} + +.manual { + margin-right: auto; + opacity: 0.85; +} + +/* Without a manual notice to push them, the actions still sit at the far end. */ +.status:last-of-type { + margin-right: auto; +} + +.actions { + display: flex; + margin-left: auto; + gap: 8px; +} diff --git a/apps/lite/ui/src/routes/project/$id/workspace/ConflictBar.tsx b/apps/lite/ui/src/routes/project/$id/workspace/ConflictBar.tsx new file mode 100644 index 00000000000..22c9c55d22f --- /dev/null +++ b/apps/lite/ui/src/routes/project/$id/workspace/ConflictBar.tsx @@ -0,0 +1,104 @@ +import { getButtonClassName } from "#ui/components/Button.tsx"; +import { classes } from "#ui/components/classes.ts"; +import { Icon } from "#ui/components/Icon.tsx"; +import { projectSlice } from "#ui/projects/state.ts"; +import { useAppDispatch, useAppSelector } from "#ui/store.ts"; +import type { FC } from "react"; +import styles from "./ConflictBar.module.css"; +import type { + ConflictedFile, + HunkResolution, + ManualConflict, + ResolutionSpec, +} from "@gitbutler/but-sdk"; + +type Props = { + projectId: string; + /** The conflicted commit the checks are scoped to. */ + commitId: string; + /** Conflicted files that decompose into hunks. */ + conflicts: Array; + /** Conflicted files that can only be resolved in edit mode. */ + manual: Array; + /** True while a resolution is in flight. */ + busy: boolean; + /** Whether the diff below is showing two columns; the sides are named for + * where they appear, and only a split has a left and right. */ + splitView: boolean; + onResolve: (specs: Array) => void; +}; + +/** + * What is still unresolved in the selected commit, and the actions for the + * conflicts that are checked. + * + * Present for the whole time a commit is conflicted rather than appearing with + * the first check, so working down the list doesn't shift the diff under the + * pointer. It also carries the files that need edit mode: they have no diff and + * no cards, so the only other place they appear is the files panel, which is + * closed by default. + */ +export const ConflictBar: FC = (p) => { + const dispatch = useAppDispatch(); + const checked = useAppSelector((state) => + projectSlice.selectors.selectCheckedConflicts(state, p.projectId, p.commitId), + ); + + const total = p.conflicts.reduce((sum, file) => sum + file.hunks.length, 0); + if (total === 0 && p.manual.length === 0) return null; + + const apply = (resolution: HunkResolution) => { + if (p.busy || checked.length === 0) return; + p.onResolve(checked.map(({ path, hunk }) => ({ path, hunk, resolution }))); + }; + + return ( +
+ + + {checked.length > 0 + ? `${checked.length} of ${total} conflict${total === 1 ? "" : "s"} selected` + : `${total} unresolved conflict${total === 1 ? "" : "s"}`} + + + {p.manual.length > 0 && ( + `${file.path} — ${file.reason}`).join("\n")} + > + {p.manual.length} file{p.manual.length === 1 ? "" : "s"} can only be resolved in edit mode + + )} + + {checked.length > 0 && ( +
+ + + +
+ )} +
+ ); +}; diff --git a/apps/lite/ui/src/routes/project/$id/workspace/ConflictCard.module.css b/apps/lite/ui/src/routes/project/$id/workspace/ConflictCard.module.css new file mode 100644 index 00000000000..852b2f8dc0f --- /dev/null +++ b/apps/lite/ui/src/routes/project/$id/workspace/ConflictCard.module.css @@ -0,0 +1,59 @@ +.wrapper { + display: flex; + flex-direction: column; + max-width: 100ch; + margin: 12px; + padding: 18px; + gap: 12px; + border-radius: var(--radius-card); + border-top-left-radius: 0; + background: var(--bg-1); +} + +.header { + display: flex; + align-items: center; + gap: 8px; +} + +.title { + font-weight: bold; +} + +.side { + display: flex; + flex-direction: column; + min-width: 0; + gap: 4px; +} + +.label { + color: var(--text-2); +} + +.content { + margin: 0; + padding: 8px 10px; + overflow-x: auto; + border-radius: var(--radius-m); + background: var(--bg-2); + white-space: pre; +} + +/* Shiki wraps its output in its own
 with an inline background. Keep its
+   token colours, drop the frame — the card already provides one. */
+.content :global(pre) {
+	margin: 0;
+	padding: 0;
+	background: transparent !important;
+}
+
+.hint {
+	color: var(--text-2);
+}
+
+.actions {
+	display: flex;
+	flex-wrap: wrap;
+	gap: 12px;
+}
diff --git a/apps/lite/ui/src/routes/project/$id/workspace/ConflictCard.tsx b/apps/lite/ui/src/routes/project/$id/workspace/ConflictCard.tsx
new file mode 100644
index 00000000000..8bd4d6a4c83
--- /dev/null
+++ b/apps/lite/ui/src/routes/project/$id/workspace/ConflictCard.tsx
@@ -0,0 +1,234 @@
+import { getButtonClassName } from "#ui/components/Button.tsx";
+import { Checkbox } from "#ui/components/Checkbox.tsx";
+import { classes } from "#ui/components/classes.ts";
+import { FieldTextareaStyles } from "#ui/components/Field.tsx";
+import { Icon } from "#ui/components/Icon.tsx";
+import { projectSlice } from "#ui/projects/state.ts";
+import { useAppDispatch, useAppSelector } from "#ui/store.ts";
+import {
+	attachResolvedLanguages,
+	getFiletypeFromFileName,
+	getHighlighterIfLoaded,
+	getResolvedOrResolveLanguage,
+	type SupportedLanguages,
+} from "@pierre/diffs";
+import { useEffect, useMemo, useState, type FC } from "react";
+import styles from "./ConflictCard.module.css";
+import type { ConflictHunk, HunkResolution } from "@gitbutler/but-sdk";
+
+type Props = {
+	projectId: string;
+	/** The conflicted commit, which the check is scoped to. */
+	commitId: string;
+	path: string;
+	/** 1-based, as the resolve API addresses conflicts. */
+	hunk: number;
+	conflict: ConflictHunk;
+	/** How many conflicts the file has, for the "N of M" title. */
+	total: number;
+	/** The shiki theme the diff renders with, so the sides match the code around them. */
+	codeTheme: string;
+	/**
+	 * Whether the diff around this card is showing two columns. The sides are
+	 * named for where they are on screen, and only a split has a left and right.
+	 */
+	splitView: boolean;
+	/**
+	 * True while any card's resolution is in flight. The mutation is owned one
+	 * level up so this covers every card: each apply rewrites the commit, so a
+	 * second one started meanwhile would address an id that no longer exists.
+	 */
+	busy: boolean;
+	onResolve: (path: string, hunk: number, resolution: HunkResolution) => void;
+};
+
+/**
+ * A conflict side as highlighted code.
+ *
+ * Uses the highlighter the diff already loaded — a card only ever renders
+ * inside a rendered diff, so it is there — and falls back to plain text when a
+ * language or theme has not been resolved, which throws rather than degrading.
+ */
+const Code: FC<{ code: string; path: string; theme: string }> = (p) => {
+	// Bumped once a language had to be fetched, to recompute with it attached.
+	const [attachedLanguages, setAttachedLanguages] = useState(0);
+	const lang = getFiletypeFromFileName(p.path);
+
+	const tokens = useMemo(() => {
+		// Read so the dependency is a real one: it is the retry signal, marking
+		// the attempt below stale once a language has been attached.
+		void attachedLanguages;
+		const highlighter = getHighlighterIfLoaded();
+		if (!highlighter) return null;
+		try {
+			return highlighter.codeToTokens(p.code, { lang, theme: p.theme });
+		} catch {
+			// The language is not attached yet; the effect below fetches it.
+			return null;
+		}
+	}, [p.code, p.theme, lang, attachedLanguages]);
+
+	useEffect(() => {
+		const highlighter = getHighlighterIfLoaded();
+		if (tokens !== null || !highlighter || lang === "text" || lang === "ansi") return;
+		// The diff attaches languages as it needs them, and a card can render
+		// before this file's is there. Resolving and attaching is what the
+		// renderer itself does, so it adds to the shared highlighter rather than
+		// displacing what the diff already has.
+		let stale = false;
+		Promise.resolve(
+			getResolvedOrResolveLanguage(lang as Exclude),
+		)
+			.then((resolved) => {
+				if (stale) return;
+				attachResolvedLanguages(resolved, highlighter);
+				setAttachedLanguages((n) => n + 1);
+			})
+			.catch(() => {});
+		return () => {
+			stale = true;
+		};
+	}, [tokens, lang]);
+
+	return (
+		
+			{tokens === null
+				? p.code
+				: tokens.tokens.map((line, lineIndex) => (
+						// Lines and tokens have no identity beyond their position.
+						// oxlint-disable-next-line no-array-index-key
+						
+							{line.map((token, tokenIndex) => (
+								// oxlint-disable-next-line no-array-index-key
+								
+									{token.content}
+								
+							))}
+							{"\n"}
+						
+					))}
+		
+ ); +}; + +/** One unresolved conflict, rendered inline in the diff where its region starts. */ +export const ConflictCard: FC = (p) => { + const dispatch = useAppDispatch(); + const [draft, setDraft] = useState(null); + const conflict = { commitId: p.commitId, path: p.path, hunk: p.hunk }; + // A primitive, so checking one conflict re-renders one card rather than all. + const checked = useAppSelector((state) => + projectSlice.selectors.selectIsConflictChecked(state, p.projectId, conflict), + ); + // Checked conflicts are resolved from the bar, so their own resolutions go + // inert — disabled rather than hidden, which would change the card's height + // and shift every conflict below it as you work down the list. The checkbox + // and Cancel stay live, or checking a card would trap you in it. + const disabled = p.busy || checked; + + const apply = (resolution: HunkResolution) => { + if (disabled) return; + p.onResolve(p.path, p.hunk, resolution); + }; + + return ( +
+
+ + dispatch( + projectSlice.actions.checkConflict({ + projectId: p.projectId, + conflict, + checked: next, + }), + ) + } + /> + +
+ {p.total > 1 ? `Unresolved conflict ${p.hunk} of ${p.total}` : "Unresolved conflict"} +
+
+ + {/* Only the ancestor. The diff either side of this card is already the + base's version against the commit's own, so repeating those two + would say twice what is on screen once. */} +
+
Common ancestor
+ {p.conflict.base === null ? ( +
+ The merge found no common ancestor for this region. +
+ ) : ( + + )} +
+ + {draft === null ? ( +
+ + + +
+ ) : ( + <> +
+ This replaces the whole conflicted region. Leaving it empty deletes the region; never + include conflict markers. +
+ setDraft(event.currentTarget.value)} + /> +
+ + +
+ + )} +
+ ); +}; diff --git a/apps/lite/ui/src/routes/project/$id/workspace/Details.module.css b/apps/lite/ui/src/routes/project/$id/workspace/Details.module.css index bfc55b867a1..3f194846652 100644 --- a/apps/lite/ui/src/routes/project/$id/workspace/Details.module.css +++ b/apps/lite/ui/src/routes/project/$id/workspace/Details.module.css @@ -381,3 +381,19 @@ .commitConflictBadge { flex-shrink: 0; } + +/* Holds the conflict bar above the diff as one child of `.panel`, whose grid + sizes exactly two rows — a third child would take the diff's. */ +.diffArea { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr); + min-height: 0; +} + +/* Grid items floor at min-content, so without this the diff sizes to its whole + length and the row scrolls the minimap away with it instead of the code area + scrolling beneath a pinned ruler. */ +.diffArea > * { + min-height: 0; +} diff --git a/apps/lite/ui/src/routes/project/$id/workspace/Details.tsx b/apps/lite/ui/src/routes/project/$id/workspace/Details.tsx index 512f7b47dbd..c9cb913cd0d 100644 --- a/apps/lite/ui/src/routes/project/$id/workspace/Details.tsx +++ b/apps/lite/ui/src/routes/project/$id/workspace/Details.tsx @@ -1,11 +1,16 @@ import { ResizeHandle } from "#ui/components/ResizeHandle.tsx"; import { Scroller } from "#ui/components/Scroller.tsx"; import { SuspenseQuery } from "@suspensive/react-query"; -import { useOpenInProgram, useSaveGUISettings } from "#ui/api/mutations.ts"; +import { + useOpenInProgram, + useResolveCommitConflictHunks, + useSaveGUISettings, +} from "#ui/api/mutations.ts"; import { branchDiffQueryOptions, changesInWorktreeQueryOptions, commentsQueryOptions, + commitConflictsQueryOptions, commitDetailsWithLineStatsQueryOptions, forgeInfoOptions, guiSettingsQueryOptions, @@ -48,7 +53,13 @@ import { import { useAppDispatch, useAppSelector, useAppStore } from "#ui/store.ts"; import { classes } from "#ui/components/classes.ts"; import { Toggle, ToggleGroup, Toolbar, Tooltip } from "@base-ui/react"; -import type { CommitDetails, TreeChange } from "@gitbutler/but-sdk"; +import type { + CommitDetails, + ConflictedFile, + ManualConflict, + ResolutionSpec, + TreeChange, +} from "@gitbutler/but-sdk"; import { type CodeViewDiffItem, type CodeView as CodeViewClass, @@ -124,6 +135,8 @@ import type { DiffLineTarget } from "./diff-line-target.ts"; import { useHunkMenuItems } from "./useHunkMenuItems.ts"; import { ChangeTypeBadge } from "./ChangeTypeBadge.tsx"; import { AnnotationCard } from "#ui/routes/project/$id/workspace/AnnotationCard.tsx"; +import { ConflictBar } from "#ui/routes/project/$id/workspace/ConflictBar.tsx"; +import { ConflictCard } from "#ui/routes/project/$id/workspace/ConflictCard.tsx"; import { annotationSideToDiffSide, annotationsByPathForScope, @@ -150,21 +163,33 @@ export type DiffViewerHandle = CodeViewHandle; type PanelId = "files-panel" | "diff-panel"; const EMPTY_ANNOTATIONS_BY_PATH: LocalAnnotationsByPath = new Map(); +const EMPTY_CONFLICTS: Array = []; +const EMPTY_MANUAL: Array = []; const getCommitFileRowItems = ({ commitDetails, + manual = EMPTY_MANUAL, }: { commitDetails: CommitDetails; + /** + * Conflicted files the resolve API cannot address. They have no diff to + * show, which is exactly what a conflict row is — so they keep the commit + * visibly conflicted while pointing at the files that need edit mode. + */ + manual?: Array; }): Array => { - const conflictedPaths = commitDetails.conflictEntries - ? globalThis.Array.from( - new Set([ - ...commitDetails.conflictEntries.ancestorEntries, - ...commitDetails.conflictEntries.ourEntries, - ...commitDetails.conflictEntries.theirEntries, - ]), - ).toSorted((a, b) => a.localeCompare(b)) - : []; + const conflictedPaths = globalThis.Array.from( + new Set([ + ...(commitDetails.conflictEntries + ? [ + ...commitDetails.conflictEntries.ancestorEntries, + ...commitDetails.conflictEntries.ourEntries, + ...commitDetails.conflictEntries.theirEntries, + ] + : []), + ...manual.map((file) => file.path), + ]), + ).toSorted((a, b) => a.localeCompare(b)); const conflictedPathSet = new Set(conflictedPaths); return [ @@ -208,7 +233,7 @@ const withAnnotations = ( // Annotations move when their backend anchor drifts, so the version must cover their // positions and identities, not just their count. const annoHash = hash( - annotations.map((a) => `${a.metadata.id}:${a.side}:${a.lineNumber}`).join(), + persistedAnnotations.map((a) => `${a.id}:${a.side}:${a.lineNumber}`).join(), ); const version = item.version; @@ -222,6 +247,53 @@ const withAnnotations = ( }), }); +/** + * Anchor each unresolved conflict at the line its region starts on. The backend + * counts that line in the commit's auto-resolved content, which is exactly what + * the diff renders, so the two agree without any mapping. + */ +const withConflictAnnotations = ( + diffView: DiffView, + conflicts: Array, +): DiffView => { + if (conflicts.length === 0) return diffView; + const byPath = new Map(conflicts.map((file) => [file.path, file])); + + return { + ...diffView, + items: diffView.items.map((item) => { + const file = diffView.fileByItemId.get(item.id); + if (!file) throw new Error("Diff view file not found by ID"); + + const conflicted = byPath.get(file.operand.path); + if (!conflicted || conflicted.hunks.length === 0) return item; + + const annotations: Array> = conflicted.hunks.map( + (conflict, index) => ({ + lineNumber: conflict.line, + side: "additions", + metadata: { _tag: "conflict", path: conflicted.path, hunk: index + 1 }, + }), + ); + + // Resolving rewrites the commit, so the conflicts that remain shift. + // The version must cover their positions, not just their count. + const annoHash = hash( + conflicted.hunks.map((conflict, index) => `${index + 1}:${conflict.line}`).join(), + ); + + const version = item.version; + if (version === undefined) throw new Error("Diff view item missing base version"); + + return { + ...item, + version: combineHashes(version, annoHash), + annotations: [...(item.annotations ?? []), ...annotations], + }; + }), + }; +}; + const DiffContents: FC<{ localAnnotationFormId: string; selectionScopeRef: RefObject; @@ -230,6 +302,11 @@ const DiffContents: FC<{ projectId: string; diffView: DiffView; annotationsByPath: LocalAnnotationsByPath; + /** The selected commit's unresolved conflicts, keyed by path. */ + conflicts: Array; + /** Owned by the parent so the batch bar and the cards share one pending state. */ + onResolveConflict: (specs: Array) => void; + resolvingConflict: boolean; diffBackgrounds?: GUISettings["diffBackground"]; diffOverflow?: GUISettings["diffOverflow"]; diffStyle?: GUISettings["diffStyle"]; @@ -243,6 +320,9 @@ const DiffContents: FC<{ projectId, diffView: { items, navigationIndex, hunkByKey, fileByItemId }, annotationsByPath, + conflicts, + onResolveConflict, + resolvingConflict, diffBackgrounds, diffOverflow, diffStyle, @@ -254,6 +334,7 @@ const DiffContents: FC<{ const newFocusableAnnotationIdRef = useRef(null); const dispatch = useAppDispatch(); const { mutate: createComment } = useCommentCreate(); + const conflictsByPath = new Map(conflicts.map((file) => [file.path, file])); const { data: editors } = useQuery(listEditorsQueryOptions); const { data: settings } = useQuery({ ...guiSettingsQueryOptions, @@ -265,8 +346,18 @@ const DiffContents: FC<{ diffTabSize: cfg.diffTabSize, lineDiffType: cfg.lineDiffType, theme: cfg.theme, + syntaxHighlighting: cfg.syntaxHighlighting, }), }); + // The concrete shiki theme the diff is already rendering with, so conflict + // snippets are highlighted the same way as the code around them. + const themeType = settings?.theme ?? defaultSettings.theme; + const prefersDark = + themeType === "dark" || + (themeType === "system" && globalThis.matchMedia("(prefers-color-scheme: dark)").matches); + const codeTheme = prefersDark + ? (settings?.syntaxHighlighting?.dark ?? defaultSettings.syntaxHighlighting.dark) + : (settings?.syntaxHighlighting?.light ?? defaultSettings.syntaxHighlighting.light); const { mutate: openInProgram } = useOpenInProgram(); const hunkMenuItems = useHunkMenuItems({ projectId }); const store = useAppStore(); @@ -595,13 +686,42 @@ const DiffContents: FC<{ ); }} renderAnnotation={(anno, item) => { - if (!isDiffAnnotation(anno)) throw new Error("Only diff items may be rendered"); + // Pinned: inference picks one member of the Annotation union and + // narrows `metadata` to it, hiding the conflict variant below. + if (!isDiffAnnotation(anno)) throw new Error("Only diff items may be rendered"); const file = fileByItemId.get(item.id); if (!file) return null; + if (anno.metadata._tag === "conflict") { + const { path, hunk } = anno.metadata; + const conflicted = conflictsByPath.get(path); + const conflict = conflicted?.hunks[hunk - 1]; + // Each apply rewrites the commit, so a card can briefly + // outlive the conflict it was rendered for. + if (!conflict || fileParent._tag !== "Commit") return null; + + return ( + + onResolveConflict([{ path, hunk, resolution }]) + } + /> + ); + } + const annotations = annotationsByPath.get(file.operand.path) ?? []; - const annotation = annotations.find(({ id }) => id === anno.metadata.id); + const annotationId = anno.metadata.id; + const annotation = annotations.find(({ id }) => id === annotationId); if (!annotation) return null; return ( @@ -934,6 +1054,10 @@ const Diff: FC<{ changes: Array; filesVisible: boolean; filesItems: Array; + /** The selected commit's unresolved conflicts, if it has any. */ + conflicts?: Array; + /** Its conflicted files that can only be resolved in edit mode. */ + manualConflicts?: Array; onActiveFileSelection: (itemId: string, firstHunk: HunkOperand | null) => void; onPassiveFileSelection: (selection: string) => void; selection: Operand; @@ -945,6 +1069,8 @@ const Diff: FC<{ changes: unsortedChanges, filesVisible, filesItems, + conflicts = EMPTY_CONFLICTS, + manualConflicts = EMPTY_MANUAL, onPassiveFileSelection, selection, projectId, @@ -956,6 +1082,10 @@ const Diff: FC<{ const localAnnotationFormId = useId(); const selectionScopeRef = useRef(null); const dispatch = useAppDispatch(); + // One mutation for the batch bar and every card, so `isPending` means "a + // resolution is in flight" rather than "this one's is". Each apply rewrites + // the commit, and a second started meanwhile would address the id it replaced. + const { mutate: resolveConflict, isPending: resolvingConflict } = useResolveCommitConflictHunks(); const changes = useMemo( () => unsortedChanges.toSorted((a, b) => compareFilePaths(a.path, b.path)), [unsortedChanges], @@ -1057,7 +1187,10 @@ const Diff: FC<{ [fileParent, shownFileIndex, changes, treeChangeDiffs], ); - const diffView = withAnnotations(diffViewSansAnno, annotationsByPath); + const diffView = withConflictAnnotations( + withAnnotations(diffViewSansAnno, annotationsByPath), + conflicts, + ); // The diff panel resolves this selection for the viewer; the ruler wants it in // file line numbers, which is what the hunk's own range already holds. @@ -1332,37 +1465,62 @@ const Diff: FC<{
-
- + {/* One panel child, so `.panel`'s two-row grid still sizes the + diff: the bar is an auto row inside this, not a third row + that would take the diff's. */} +
+ {fileParent._tag === "Commit" && ( + + resolveConflict({ projectId, commitId: fileParent.commitId, specs }) + } + /> + )} - {minimapShown && ( - + + fileParent._tag === "Commit" && + resolveConflict({ projectId, commitId: fileParent.commitId, specs }) + } + resolvingConflict={resolvingConflict} + diffBackgrounds={diffSettings?.diffBackground} + diffOverflow={diffSettings?.diffOverflow} + diffStyle={diffStyle} + selectionScopeRef={selectionScopeRef} + viewerRef={viewerRef} + didScrollToViaFileRef={didScrollToViaFileRef} /> - )} + + {minimapShown && ( + + )} +
@@ -1455,6 +1613,36 @@ const CommitDetails: FC<{ commitDetailsWithLineStatsQueryOptions({ projectId, commitId: selection.commitId }), ); + const { data: conflicts } = useQuery( + commitConflictsQueryOptions({ + projectId, + commitId: selection.commitId, + enabled: commitDetails.commit.hasConflicts, + }), + ); + + // A commit's changes are diffed against its auto-resolution, which keeps the + // base's version wherever the merge conflicted — so a file whose conflict the + // auto-resolution swallows entirely is absent here and would diff to nothing. + // Append the base-vs-commit change the backend synthesises for exactly those, + // leaving files that already diff to something alone. + // + // Both arrays are memoised because their identity is load-bearing downstream: + // `filesRows` is keyed on `filesItems`, and `getDiffView` re-runs on a fresh + // `changes`. + const changes = useMemo(() => { + const swallowed = conflicts?.files.filter( + (file) => !commitDetails.changes.some((change) => change.path === file.path), + ); + if (swallowed === undefined || swallowed.length === 0) return commitDetails.changes; + return [...commitDetails.changes, ...swallowed.map((file) => file.change)]; + }, [commitDetails.changes, conflicts]); + + const filesItems = useMemo( + () => getCommitFileRowItems({ commitDetails, manual: conflicts?.manual }), + [commitDetails, conflicts], + ); + const fmtDate = new Intl.DateTimeFormat(undefined, { day: "2-digit", month: "2-digit", @@ -1556,9 +1744,11 @@ const CommitDetails: FC<{ > = { ciChecks: [], commentReactions: [], comments: ["gitActivity", "workspaceActivity", "worktreeChanges"], + // Derived from the trees the commit itself carries, so nothing outside it + // can change the answer. Resolving rewrites the commit, which moves the + // query to a new key rather than staling this one. + commitConflicts: [], commitDetailsWithLineStats: ["gitActivity", "workspaceActivity"], currentForgeLogin: [], dryRun: ["gitActivity", "workspaceActivity", "worktreeChanges"], diff --git a/crates/but-api/src/resolve/apply.rs b/crates/but-api/src/resolve/apply.rs index fb5376ff8fd..4456b927e5f 100644 --- a/crates/but-api/src/resolve/apply.rs +++ b/crates/but-api/src/resolve/apply.rs @@ -31,7 +31,9 @@ pub(crate) struct AppliedResolution { /// Whether the fully resolved commit ended up with the same tree as its /// parent, i.e. the resolutions dropped all of its changes. pub commit_emptied: bool, - /// The conflicts that remain per file; empty when fully resolved. + /// The conflicts that remain per file; empty when every addressable + /// conflict is resolved. The commit can still be conflicted with `manual` + /// files even when this is empty. pub remaining: Vec, /// Workspace state after the apply. pub workspace: WorkspaceState, @@ -96,7 +98,23 @@ pub(crate) fn locate_hunk( ) -> anyhow::Result<(usize, usize)> { let &file_index = files_by_path .get(&normalize_path(path)) - .with_context(|| format!("\"{path}\" is not a conflicted file of this commit"))?; + .with_context(|| { + // Naming the reason matters here: the file *is* conflicted, it just + // has no hunks to address, so "not a conflicted file" would read as + // a caller mistake rather than a property of the conflict. + let normalized = normalize_path(path); + match request + .manual + .iter() + .find(|file| normalize_path(&file.path) == normalized) + { + Some(file) => format!( + "\"{path}\" cannot be resolved this way: {} Resolve this commit in edit mode instead.", + file.reason + ), + None => format!("\"{path}\" is not a conflicted file of this commit"), + } + })?; let file = &request.files[file_index]; if hunk == 0 || hunk > file.hunks.len() { bail!( @@ -335,7 +353,9 @@ pub(crate) fn apply( }) }) .collect(); - if unresolved && remaining.is_empty() { + // Only holds when every conflict was hunk-addressable: a file in `manual` + // is never narrowed, so it keeps conflicting however many hunks were resolved. + if unresolved && remaining.is_empty() && request.manual.is_empty() { bail!( "BUG: all conflicts of commit {} were resolved, yet re-merging the narrowed trees still conflicts", request.commit_id @@ -524,6 +544,7 @@ mod tests { ours_tree_id: gix::ObjectId::null(gix::hash::Kind::Sha1), theirs_tree_id: gix::ObjectId::null(gix::hash::Kind::Sha1), files, + manual: Vec::new(), } } diff --git a/crates/but-api/src/resolve/context.rs b/crates/but-api/src/resolve/context.rs index f2f66259117..92645516b5d 100644 --- a/crates/but-api/src/resolve/context.rs +++ b/crates/but-api/src/resolve/context.rs @@ -87,15 +87,35 @@ pub struct ResolutionRequest { pub ours_tree_id: gix::ObjectId, /// The commit's stored *theirs* tree, i.e. its own version. pub theirs_tree_id: gix::ObjectId, - /// All conflicted files, sorted by path. + /// All conflicted files that decompose into hunks, sorted by path. pub files: Vec, + /// Conflicted files that have no hunk representation, sorted by path. + /// They cannot be addressed through this API at all, so a commit is only + /// fully resolved once this is empty. + pub manual: Vec, } +/// A conflicted file with no hunk representation — a side deletion or rename, a +/// non-blob entry, a binary, or one too large to splice — which therefore needs +/// manual resolution in edit mode. +#[derive(Debug, Clone, serde::Serialize)] +#[cfg_attr(feature = "export-schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct ManualConflict { + /// The repo-relative path of the file. + pub path: String, + /// Why it cannot be resolved automatically, for display to the user. + pub reason: String, +} + +#[cfg(feature = "export-schema")] +but_schemars::register_sdk_type!(ManualConflict); + /// Re-merge the conflict trees of `commit_id` and extract all conflict hunks. /// -/// Fails with a "resolve manually" style error for conflicts that have no -/// marker block to splice a resolution into: side deletions, non-blob entries, -/// binary or oversized files. +/// Conflicts with no marker block to splice a resolution into — side deletions, +/// non-blob entries, binary or oversized files — are reported in `manual` +/// rather than failing the request, so the rest of the commit stays workable. pub fn build_request( repo: &gix::Repository, commit_id: gix::ObjectId, @@ -160,12 +180,24 @@ pub fn build_request( let merged_tree = repo.find_tree(merged_tree_id)?; let mut files = Vec::with_capacity(sides_by_path.len()); + let mut manual = Vec::new(); + // A file with no hunk representation is reported rather than failing the + // whole commit: the conflicts that *can* be addressed stay addressable, and + // the commit simply remains conflicted until this one is resolved in edit + // mode. Failing outright would hide every other conflict behind one binary. + macro_rules! needs_manual_resolution { + ($path:expr, $reason:expr) => {{ + manual.push(ManualConflict { + path: $path, + reason: $reason, + }); + continue; + }}; + } for (rela_path, sides) in sides_by_path { let path = rela_path.to_str_lossy().into_owned(); if !(sides.ours && sides.theirs) { - bail!( - "The conflict in \"{path}\" involves a deletion or rename and cannot be resolved automatically. Resolve this commit manually instead." - ); + needs_manual_resolution!(path, "The conflict involves a deletion or rename.".into()); } let entry = merged_tree .lookup_entry(rela_path.split(|b| *b == b'/'))? @@ -177,23 +209,16 @@ pub fn build_request( entry_kind, gix::objs::tree::EntryKind::Blob | gix::objs::tree::EntryKind::BlobExecutable ) { - bail!( - "The conflict in \"{path}\" is not a regular file and cannot be resolved automatically. Resolve this commit manually instead." - ); + needs_manual_resolution!(path, "The conflict is not a regular file.".into()); } let blob = entry.object()?.into_blob(); if blob.data.len() > MAX_FILE_SIZE { - bail!( - "The conflicted file \"{path}\" exceeds the 1MB size limit for automatic resolution. Resolve this commit manually instead." - ); + needs_manual_resolution!(path, "The file exceeds the 1MB size limit.".into()); } - let merged_text = std::str::from_utf8(&blob.data) - .map_err(|_| { - anyhow::anyhow!( - "The conflicted file \"{path}\" is binary or not valid UTF-8 and cannot be resolved automatically. Resolve this commit manually instead." - ) - })? - .to_owned(); + let Ok(merged_text) = std::str::from_utf8(&blob.data) else { + needs_manual_resolution!(path, "The file is binary or not valid UTF-8.".into()); + }; + let merged_text = merged_text.to_owned(); let lines = split_lines(&merged_text); let blocks = scan_conflict_blocks(&lines); // Content that merely looks like a conflict marker cannot open or @@ -201,14 +226,13 @@ pub fn build_request( // would confuse the section decomposition or any later marker-based // reader of the resolved file, so hand such files to manual resolution. if let Some(line) = find_ambiguous_marker_line(&lines, &blocks) { - bail!( - "The conflicted file \"{path}\" contains content that is ambiguous with conflict markers ({line:?}) and cannot be resolved automatically. Resolve this commit manually instead." + needs_manual_resolution!( + path, + format!("The file contains content ambiguous with conflict markers ({line:?}).") ); } if blocks.is_empty() { - bail!( - "No conflict markers were found in the conflicted file \"{path}\". Resolve this commit manually instead." - ); + needs_manual_resolution!(path, "No conflict markers were found in the file.".into()); } let hunks = extract_hunks(&lines, &blocks); files.push(FileConflict { @@ -220,7 +244,7 @@ pub fn build_request( }); } - if files.is_empty() { + if files.is_empty() && manual.is_empty() { bail!("Commit {commit_id} has no conflicted files to resolve"); } @@ -232,6 +256,7 @@ pub fn build_request( ours_tree_id: ours, theirs_tree_id: theirs, files, + manual, }) } diff --git a/crates/but-api/src/resolve/mod.rs b/crates/but-api/src/resolve/mod.rs index 126f6090450..ca62353a8fe 100644 --- a/crates/but-api/src/resolve/mod.rs +++ b/crates/but-api/src/resolve/mod.rs @@ -34,7 +34,7 @@ mod context; mod prompt; pub use apply::normalize_path; -pub use context::{ConflictHunk, FileConflict, ResolutionRequest}; +pub use context::{ConflictHunk, FileConflict, ManualConflict, ResolutionRequest}; pub use prompt::{FileResolution, HunkContent, ResolutionResponse, SYSTEM_PROMPT}; /// The conflicts of a conflicted commit, as per-file hunks. @@ -42,8 +42,12 @@ pub use prompt::{FileResolution, HunkContent, ResolutionResponse, SYSTEM_PROMPT} pub struct CommitConflicts { /// The conflicted commit. pub commit_id: gix::ObjectId, - /// The conflicted files, sorted by path. + /// The conflicted files that decompose into hunks, sorted by path. pub files: Vec, + /// Conflicted files that have no hunk representation and so cannot be + /// resolved through this API at all. They need edit mode, and the commit + /// stays conflicted until they are dealt with there. + pub manual: Vec, } /// One conflicted file and its conflicts, in file order. @@ -76,7 +80,7 @@ but_schemars::register_sdk_type!(ConflictedFile); /// result. Fails for commits whose conflicts have no hunk representation /// (deletions/renames, binaries, oversized files, marker-like content) — those /// need manual resolution in edit mode. -#[but_api(try_from = crate::resolve::json::CommitConflicts)] +#[but_api(napi, try_from = crate::resolve::json::CommitConflicts)] #[instrument(err(Debug))] pub fn commit_conflicts( ctx: &but_ctx::Context, @@ -93,6 +97,7 @@ pub fn commit_conflicts( return Ok(CommitConflicts { commit_id, files: Vec::new(), + manual: Vec::new(), }); } let request = context::build_request(&repo, commit_id)?; @@ -131,6 +136,7 @@ pub fn commit_conflicts( Ok(CommitConflicts { commit_id: request.commit_id, files, + manual: request.manual, }) } @@ -213,9 +219,13 @@ pub struct HunkResolutionResult { /// Whether the fully resolved commit ended up with the same tree as its /// parent — the resolutions dropped all of its changes. pub commit_emptied: bool, - /// The conflicts that remain, per file. Empty when the commit is fully - /// resolved. + /// The conflicts that remain, per file. Empty when every hunk-addressable + /// conflict is resolved. pub remaining: Vec, + /// Conflicted files this API cannot address at all. The commit stays + /// conflicted while this is non-empty, however many hunks were resolved, + /// so a caller reporting "fully resolved" must check both lists. + pub manual: Vec, /// Workspace state after the apply. pub workspace: WorkspaceState, } @@ -231,7 +241,7 @@ pub struct HunkResolutionResult { /// [`HunkResolution::Ai`] specs are sent to the configured LLM first (no /// worktree lock is held during the model call); AI configuration is only /// required when such a spec is present. -#[but_api(try_from = crate::resolve::json::HunkResolutionResult)] +#[but_api(napi, try_from = crate::resolve::json::HunkResolutionResult)] #[instrument(skip(specs), err(Debug))] pub fn resolve_commit_conflict_hunks( ctx: &mut but_ctx::Context, @@ -308,6 +318,7 @@ pub fn resolve_commit_conflict_hunks_with( resolved, commit_emptied: applied.commit_emptied, remaining: applied.remaining, + manual: request.manual, workspace: applied.workspace, }) } @@ -349,6 +360,8 @@ fn resolve_ai_specs( base_tree_id: request.base_tree_id, ours_tree_id: request.ours_tree_id, theirs_tree_id: request.theirs_tree_id, + // The model only ever sees the hunks it was asked to merge. + manual: Vec::new(), files: targets_per_file .iter() .map(|(&file_index, targets)| { @@ -476,6 +489,17 @@ pub fn resolve_commit_conflicts_with( let repo = ctx.repo.get()?; context::build_request(&repo, commit_id)? }; + // This path promises a fully resolved commit, so a file the model cannot be + // shown is a hard stop rather than something to leave behind — unlike + // `resolve_commit_conflict_hunks()`, which resolves what it is asked to and + // leaves the rest conflicted by design. + if let Some(file) = request.manual.first() { + bail!( + "The conflict in \"{}\" cannot be resolved automatically: {} Resolve this commit in edit mode instead.", + file.path, + file.reason + ); + } // The model call happens without any worktree lock; the request is plain // data and the apply step below re-reads the workspace under the exclusive @@ -624,8 +648,10 @@ pub mod json { /// The conflicted commit. #[cfg_attr(feature = "export-schema", schemars(with = "String"))] pub commit_id: HexHash, - /// The conflicted files, sorted by path. + /// The conflicted files that decompose into hunks, sorted by path. pub files: Vec, + /// Conflicted files that need manual resolution in edit mode. + pub manual: Vec, } #[cfg(feature = "export-schema")] @@ -635,10 +661,15 @@ pub mod json { type Error = anyhow::Error; fn try_from(value: super::CommitConflicts) -> Result { - let super::CommitConflicts { commit_id, files } = value; + let super::CommitConflicts { + commit_id, + files, + manual, + } = value; Ok(Self { commit_id: commit_id.into(), files, + manual, }) } } @@ -661,6 +692,8 @@ pub mod json { pub commit_emptied: bool, /// The conflicts that remain, per file. pub remaining: Vec, + /// Conflicted files that need manual resolution in edit mode. + pub manual: Vec, /// Workspace state after the apply. pub workspace: crate::json::WorkspaceState, } @@ -678,6 +711,7 @@ pub mod json { resolved, commit_emptied, remaining, + manual, workspace, } = value; Ok(Self { @@ -686,6 +720,7 @@ pub mod json { resolved, commit_emptied, remaining, + manual, workspace: workspace.try_into()?, }) } diff --git a/crates/but-api/tests/api/resolve_hunks.rs b/crates/but-api/tests/api/resolve_hunks.rs index 996423bf2e2..50aaba244de 100644 --- a/crates/but-api/tests/api/resolve_hunks.rs +++ b/crates/but-api/tests/api/resolve_hunks.rs @@ -9,7 +9,7 @@ fn conflicted_context() -> Result<(but_ctx::Context, gix::ObjectId, tempfile::Te let (repo, tmp) = crate::support::writable_scenario("resolve-ai-conflicted-commit"); crate::support::persist_default_target(&repo)?; let conflicted_commit = repo.rev_parse_single("refs/tags/conflicted")?.detach(); - let ctx = but_ctx::Context::from_repo(repo)?.with_memory_app_cache(); + let ctx = but_ctx::Context::from_repo_for_testing(repo)?.with_memory_app_cache(); Ok((ctx, conflicted_commit, tmp)) } @@ -318,3 +318,97 @@ fn conflicts_of_a_normal_commit_are_empty_not_an_error() -> Result<()> { assert!(conflicts.files.is_empty()); Ok(()) } + +fn mixed_conflicted_context() -> Result<(but_ctx::Context, gix::ObjectId, tempfile::TempDir)> { + let (repo, tmp) = crate::support::writable_scenario("resolve-mixed-conflicted-commit"); + crate::support::persist_default_target(&repo)?; + let conflicted_commit = repo.rev_parse_single("refs/tags/conflicted")?.detach(); + let ctx = but_ctx::Context::from_repo_for_testing(repo)?.with_memory_app_cache(); + Ok((ctx, conflicted_commit, tmp)) +} + +/// A conflict with no hunk representation used to fail the whole request, which +/// hid every other conflict in the commit behind it — and left a caller with no +/// edit mode, like lite, with nothing at all to show. +#[test] +fn a_conflict_without_hunks_is_reported_not_fatal() -> Result<()> { + let (ctx, conflicted_commit, _tmp) = mixed_conflicted_context()?; + + let conflicts = commit_conflicts(&ctx, conflicted_commit)?; + + // The text conflict is still fully addressable. + assert_eq!( + conflicts.files.len(), + 1, + "the binary must not suppress the file that does decompose into hunks" + ); + assert_eq!(conflicts.files[0].path, "conflict"); + assert_eq!(conflicts.files[0].hunks.len(), 2); + + // The binary is named, with a reason to show the user. + assert_eq!(conflicts.manual.len(), 1); + assert_eq!(conflicts.manual[0].path, "binary"); + assert!( + conflicts.manual[0].reason.contains("binary"), + "reason should say why, got {:?}", + conflicts.manual[0].reason + ); + Ok(()) +} + +/// Resolving every addressable hunk still leaves the commit conflicted when a +/// manual-only file remains, so the result must say so rather than reporting a +/// clean commit — and must not trip the "all resolved yet still conflicting" +/// invariant, which only holds when every conflict was addressable. +#[test] +fn resolving_every_hunk_leaves_a_manual_conflict_behind() -> Result<()> { + let (mut ctx, conflicted_commit, _tmp) = mixed_conflicted_context()?; + + let result = resolve_commit_conflict_hunks( + &mut ctx, + conflicted_commit, + vec![ + spec("conflict", 1, HunkResolution::Theirs), + spec("conflict", 2, HunkResolution::Theirs), + ], + )?; + + assert_eq!(result.resolved, 2); + assert!( + result.remaining.is_empty(), + "every hunk-addressable conflict was resolved" + ); + assert_eq!( + result.manual.len(), + 1, + "the binary is still unresolved, so the commit is not done" + ); + + let repo = ctx.repo.get()?; + assert!( + but_core::Commit::from_id(result.new_commit.attach(&repo))?.is_conflicted(), + "a commit with a manual conflict left stays conflicted" + ); + Ok(()) +} + +/// Addressing a file that has no hunks should explain why rather than claim the +/// path is not conflicted at all, which reads as a caller mistake. +#[test] +fn addressing_a_manual_conflict_explains_why() -> Result<()> { + let (mut ctx, conflicted_commit, _tmp) = mixed_conflicted_context()?; + + let err = resolve_commit_conflict_hunks( + &mut ctx, + conflicted_commit, + vec![spec("binary", 1, HunkResolution::Ours)], + ) + .unwrap_err(); + + let message = format!("{err:#}"); + assert!( + message.contains("binary") && message.contains("edit mode"), + "error should name the reason and the way out, got {message:?}" + ); + Ok(()) +} diff --git a/crates/but-api/tests/fixtures/scenario/resolve-mixed-conflicted-commit.sh b/crates/but-api/tests/fixtures/scenario/resolve-mixed-conflicted-commit.sh new file mode 100755 index 00000000000..50277dd4418 --- /dev/null +++ b/crates/but-api/tests/fixtures/scenario/resolve-mixed-conflicted-commit.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash + +set -eu -o pipefail + +git init +echo "A conflicted commit mixing a hunk-addressable conflict with a binary one" >.git/description + +git config user.name GitButler +git config user.email gitbutler@example.com + +echo unrelated >file +git add . && git commit -m "init" + +mkdir -p .git/refs/remotes/origin +cp .git/refs/heads/main .git/refs/remotes/origin/main + +cat <>.git/config +[remote "origin"] + url = ./fake/local/path/which-is-fine-as-we-dont-fetch-or-push + fetch = +refs/heads/*:refs/remotes/origin/* +EOF + +# A conflicted commit as GitButler would write it after a rebase: the merge +# inputs are kept as trees, the commit tree is auto-resolved favoring "ours", +# and the conflict is recorded in the message trailer plus the legacy header. +# +# The content conflicts: base, ours (the new base), and theirs (the commit's +# own version) each differ at "line two" and at "line six" of "conflict", +# far enough apart to form two separate conflict hunks. +unrelated_blob=$(git rev-parse HEAD:file) +base_blob=$(printf "line one\nline two\nline three\nline four\nline five\nline six\nline seven\n" | git hash-object -wt blob --stdin) +ours_blob=$(printf "line one\nline two changed by the new base\nline three\nline four\nline five\nline six changed by the new base\nline seven\n" | git hash-object -wt blob --stdin) +theirs_blob=$(printf "line one\nline two changed by this commit\nline three\nline four\nline five\nline six changed by this commit\nline seven\n" | git hash-object -wt blob --stdin) +# A binary file conflicting on both sides too. It has no marker representation, +# so it can only ever be resolved in edit mode — the point of the fixture is +# that its presence must not hide the text conflicts above. +base_bin=$(printf 'bin\xff\xfe base\n' | git hash-object -wt blob --stdin) +ours_bin=$(printf 'bin\xff\xfe ours\n' | git hash-object -wt blob --stdin) +theirs_bin=$(printf 'bin\xff\xfe theirs\n' | git hash-object -wt blob --stdin) + +conflict_files_blob=$(git hash-object -wt blob --stdin < 1730625617 +0100 +committer GitButler 1730625617 +0100 +gitbutler-headers-version 2 +change-id 00000000-0000-0000-0000-000000000001 +gitbutler-conflicted 1 + +[conflict] Change line two + +GitButler-Conflict: fixture marker + +EOF +) +git tag conflicted "$conflict_commit" + +# A normal descendant of the conflicted commit. Its tree is built on the +# conflicted commit's auto-resolution, like the rebase engine would do. +later_blob=$(printf "descendant\n" | git hash-object -wt blob --stdin) +git read-tree --empty +git update-index --add --cacheinfo 100644 "$unrelated_blob" "file" +git update-index --add --cacheinfo 100644 "$ours_blob" "conflict" +git update-index --add --cacheinfo 100644 "$ours_bin" "binary" +git update-index --add --cacheinfo 100644 "$later_blob" "later" +descendant_tree=$(git write-tree) +descendant_commit=$(git commit-tree "$descendant_tree" -p "$conflict_commit" -m "descendant") +git update-ref refs/heads/branchy "$descendant_commit" + +git symbolic-ref HEAD refs/heads/branchy +git reset --hard >/dev/null diff --git a/crates/but/src/command/legacy/resolve.rs b/crates/but/src/command/legacy/resolve.rs index f3d4642c26c..8b1800aaca6 100644 --- a/crates/but/src/command/legacy/resolve.rs +++ b/crates/but/src/command/legacy/resolve.rs @@ -637,14 +637,14 @@ enum CommitOrBranch { /// Resolve a user-provided identifier to a commit or a branch. fn parse_commit_or_branch(ctx: &mut Context, target: &str) -> Result { - let id_map = IdMap::legacy_new_from_context(ctx, None)?; + let id_map = IdMap::legacy_new_from_context(ctx)?; let matches = id_map.parse_using_context(target, ctx)?; match matches.as_slice() { [] => bail!( "\"{target}\" is neither a commit nor a branch. Try running 'but status' to see what is available." ), - [CliId::Commit { commit_id, .. }] => Ok(CommitOrBranch::Commit(*commit_id)), - [CliId::Branch { name, .. }] => Ok(CommitOrBranch::Branch(name.clone())), + [CliId::Commit { commit, .. }] => Ok(CommitOrBranch::Commit(commit.commit_id)), + [CliId::Branch(branch)] => Ok(CommitOrBranch::Branch(branch.name.clone())), [_] => bail!("\"{target}\" does not refer to a commit or a branch"), _ => bail!( "\"{target}\" is ambiguous. Please provide more characters to uniquely identify it." diff --git a/packages/but-sdk/src/generated/graph/apiParamNames.d.ts b/packages/but-sdk/src/generated/graph/apiParamNames.d.ts index 8dd9face820..808f799e587 100644 --- a/packages/but-sdk/src/generated/graph/apiParamNames.d.ts +++ b/packages/but-sdk/src/generated/graph/apiParamNames.d.ts @@ -29,6 +29,7 @@ export declare const apiParamNames: { readonly commentsList: readonly ["projectId"]; readonly commitAmend: readonly ["projectId", "commitId", "changes", "changesSource", "dryRun"]; readonly commitCherryPick: readonly ["projectId", "sourceCommitIds", "relativeTo", "side", "dryRun"]; + readonly commitConflicts: readonly ["projectId", "commitId"]; readonly commitCreate: readonly ["projectId", "relativeTo", "side", "changes", "changesSource", "message", "dryRun"]; readonly commitDetailsWithLineStats: readonly ["projectId", "commitId"]; readonly commitDiscard: readonly ["projectId", "subjectCommitId", "dryRun"]; @@ -104,6 +105,7 @@ export declare const apiParamNames: { readonly removeReviewLabel: readonly ["projectId", "reviewId", "label"]; readonly removeReviewReaction: readonly ["projectId", "reviewId", "reactionId"]; readonly requestReview: readonly ["projectId", "reviewId", "logins"]; + readonly resolveCommitConflictHunks: readonly ["projectId", "commitId", "specs"]; readonly restoreSnapshotWithKind: readonly ["projectId", "restoreKind", "sha"]; readonly reviewApply: readonly ["projectId", "reviewId"]; readonly reviewTemplate: readonly ["projectId"]; diff --git a/packages/but-sdk/src/generated/graph/apiParamNames.js b/packages/but-sdk/src/generated/graph/apiParamNames.js index 36ec8cc4e83..6a077431740 100644 --- a/packages/but-sdk/src/generated/graph/apiParamNames.js +++ b/packages/but-sdk/src/generated/graph/apiParamNames.js @@ -29,6 +29,7 @@ export const apiParamNames = { commentsList: ["projectId"], commitAmend: ["projectId", "commitId", "changes", "changesSource", "dryRun"], commitCherryPick: ["projectId", "sourceCommitIds", "relativeTo", "side", "dryRun"], + commitConflicts: ["projectId", "commitId"], commitCreate: ["projectId", "relativeTo", "side", "changes", "changesSource", "message", "dryRun"], commitDetailsWithLineStats: ["projectId", "commitId"], commitDiscard: ["projectId", "subjectCommitId", "dryRun"], @@ -104,6 +105,7 @@ export const apiParamNames = { removeReviewLabel: ["projectId", "reviewId", "label"], removeReviewReaction: ["projectId", "reviewId", "reactionId"], requestReview: ["projectId", "reviewId", "logins"], + resolveCommitConflictHunks: ["projectId", "commitId", "specs"], restoreSnapshotWithKind: ["projectId", "restoreKind", "sha"], reviewApply: ["projectId", "reviewId"], reviewTemplate: ["projectId"], diff --git a/packages/but-sdk/src/generated/graph/index.d.ts b/packages/but-sdk/src/generated/graph/index.d.ts index eb6480464a1..d9ccd8d3cf4 100644 --- a/packages/but-sdk/src/generated/graph/index.d.ts +++ b/packages/but-sdk/src/generated/graph/index.d.ts @@ -263,6 +263,19 @@ export declare function commitAmend(projectId: string, commitId: string, changes */ export declare function commitCherryPick(projectId: string, sourceCommitIds: Array, relativeTo: RelativeTo, side: InsertSide, dryRun: boolean): Promise +/** + * Return the conflicts of the conflicted commit `commit_id` without entering + * edit mode or touching the working tree. + * + * Hunks are identified by `(path, 1-based index)`; the extraction is + * deterministic, so the same commit id always yields the same hunks and + * `resolve_commit_conflict_hunks()` can be called with indices from this + * result. Fails for commits whose conflicts have no hunk representation + * (deletions/renames, binaries, oversized files, marker-like content) — those + * need manual resolution in edit mode. + */ +export declare function commitConflicts(projectId: string, commitId: string): Promise + /** * Insert a new commit built from the `changes` of `changes_source` and record * an oplog snapshot on success. @@ -846,6 +859,21 @@ export declare function removeReviewReaction(projectId: string, reviewId: number /** Request reviews from the given users on a review. */ export declare function requestReview(projectId: string, reviewId: number, logins: Array): Promise +/** + * Apply `specs` to the conflicted commit `commit_id` and rebase descendants. + * + * Resolving a subset of the conflicts rewrites the commit into a conflicted + * commit with only the remaining conflicts; resolving all of them rewrites it + * into a normal commit. Either way the commit id changes — address follow-up + * resolutions to the returned `new_commit`. An oplog snapshot records an undo + * point. Nothing is written if any spec fails validation. + * + * [`HunkResolution::Ai`] specs are sent to the configured LLM first (no + * worktree lock is held during the model call); AI configuration is only + * required when such a spec is present. + */ +export declare function resolveCommitConflictHunks(projectId: string, commitId: string, specs: Array): Promise + /** * Restores the project to a specific snapshot using a specific kind of restore. This operation * also creates a new snapshot in the oplog. @@ -1799,6 +1827,16 @@ export type CommitCherryPickResult = { workspace: WorkspaceState; }; +/** JSON transport type for the conflicts of a conflicted commit. */ +export type CommitConflicts = { + /** The conflicted commit. */ + commitId: string; + /** The conflicted files that decompose into hunks, sorted by path. */ + files: Array; + /** Conflicted files that need manual resolution in edit mode. */ + manual: Array; +}; + /** JSON transport type for creating a commit in the rebase graph. */ export type CommitCreateResult = { /** The new commit if one was created. */ @@ -2717,6 +2755,8 @@ export type HunkResolutionResult = { commitEmptied: boolean; /** The conflicts that remain, per file. */ remaining: Array; + /** Conflicted files that need manual resolution in edit mode. */ + manual: Array; /** Workspace state after the apply. */ workspace: WorkspaceState; }; @@ -2951,6 +2991,18 @@ export type LoginToken = { url: string; }; +/** + * A conflicted file with no hunk representation — a side deletion or rename, a + * non-blob entry, a binary, or one too large to splice — which therefore needs + * manual resolution in edit mode. + */ +export type ManualConflict = { + /** The repo-relative path of the file. */ + path: string; + /** Why it cannot be resolved automatically, for display to the user. */ + reason: string; +}; + /** * An optional full reference name accepted as a string like `refs/heads/main`, * for use as a parameter transport via `#[but_api(...)]`. @@ -3284,6 +3336,19 @@ export type RepoPermissions = { pull: boolean; }; +/** + * One conflict to resolve, addressed by path and 1-based hunk index as + * returned by `commit_conflicts()`. + */ +export type ResolutionSpec = { + /** The repo-relative path of the conflicted file. */ + path: string; + /** The 1-based index of the conflict within the file. */ + hunk: number; + /** How to resolve it. */ + resolution: HunkResolution; +}; + /** How one conflicted file was resolved, for display to the user. */ export type ResolvedFile = { /** The repo-relative path of the file. */ diff --git a/packages/but-sdk/src/generated/graph/index.js b/packages/but-sdk/src/generated/graph/index.js index 791a6987bfd..1530a4094f4 100644 --- a/packages/but-sdk/src/generated/graph/index.js +++ b/packages/but-sdk/src/generated/graph/index.js @@ -579,7 +579,7 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { absorb, absorptionPlan, addCommentReaction, addReviewLabels, addReviewReaction, apply, applyBranchIntegration, assignHunk, branchCannedName, branchCheckout, branchCheckoutNew, branchCreate, branchDetails, branchDiff, branchLand, branchList, branchRemove, branchRename, changesInWorktree, changesInWorktreeWithPerm, checkGithubAuthStatus, checkSigningSettings, commentArchive, commentCreate, commentsList, commentUpdate, commitAmend, commitCherryPick, commitCreate, commitDetailsWithLineStats, commitDiscard, commitDiscardChanges, commitInsertBlank, commitMove, commitMoveChangesBetween, commitReword, commitSquash, commitUncommit, commitUncommitChanges, commitUncommitChangesFromCommits, createReviewComment, currentForgeLogin, deleteAllData, deleteProject, deleteReviewComment, deleteUser, discardWorktreeChanges, forgeCompareBranchUrl, forgeInfo, forgeProvider, forgetBitbucketAccount, forgetGithubAccount, forgetGitlabAccount, getBbUser, getGbConfig, getGhUser, getGlUser, getInitialBranchIntegration, getLoginToken, getRedoTargetSnapshot, getRepoInfo, getReview, getReviewBaseRepoUrl, getReviewMergeStatus, getTerminalOptionsForPlatform, getUndoTargetSnapshot, getUserProfileLocal, getWorkspace, gitTestFetch, gitTestPush, headInfo, initApplicationNamespace, initGithubDeviceOauth, listAvailableReviewTemplates, listBranches, listCiChecks, listCommentReactions, listEditors, listKnownBitbucketAccounts, listKnownGithubAccounts, listKnownGitlabAccounts, listPrograms, listProjectsStateless, listRepoLabels, listReviewComments, listReviewerCandidates, listReviewReactions, listReviews, listReviewsForBranch, listReviewSubmissions, listReviewTimelineEvents, loginAndPersist, mergeReview, moveBranch, openInProgram, openInTerminal, peelRestoreSnapshot, ProgramCategory, publishReview, removeBranch, removeCommentReaction, removeReviewLabel, removeReviewReaction, requestReview, restoreSnapshotWithKind, reviewApply, reviewTemplate, setGbConfig, setPushRemote, setReviewAutoMerge, setReviewDraftiness, setReviewTemplate, setTargetRefAndInitProject, storeBitbucketApiToken, storeGithubPat, storeGitlabPat, tearOffBranch, treeChangeDiffs, unapplyStack, updateBranchName, updateProfileAndPersist, updateProjectSettings, updateReview, updateReviewComment, updateReviewFooters, warmCiChecksCache, withdrawReviewRequest, workspaceBranchAndAncestorsPush, workspaceCheckout, workspaceFetchFromRemotes, workspaceFetchStatus, workspaceIntegrateUpstream, workspaceTargetCommits, WatcherHandle, ANY_FORK, ANY_FORK_OR_MERGE, ANY_MERGE, askpassInit, askpassSubmitPromptResponse, CHILD, getAppSettings, HORIZ_ANCESTOR, HORIZ_PARENT, HORIZONTAL, LEFT_FORK, LEFT_FORK_ANCESTOR, LEFT_FORK_PARENT, LEFT_MERGE, LEFT_MERGE_ANCESTOR, LEFT_MERGE_PARENT, RIGHT_FORK, RIGHT_FORK_ANCESTOR, RIGHT_FORK_PARENT, RIGHT_MERGE, RIGHT_MERGE_ANCESTOR, RIGHT_MERGE_PARENT, updateFeatureFlags, updateFetch, updateOnboardingComplete, updateReviews, updateTelemetry, updateTelemetryDistinctId, updateUi, VERT_ANCESTOR, VERT_PARENT, VERTICAL, watcherStart } = nativeBinding +const { absorb, absorptionPlan, addCommentReaction, addReviewLabels, addReviewReaction, apply, applyBranchIntegration, assignHunk, branchCannedName, branchCheckout, branchCheckoutNew, branchCreate, branchDetails, branchDiff, branchLand, branchList, branchRemove, branchRename, changesInWorktree, changesInWorktreeWithPerm, checkGithubAuthStatus, checkSigningSettings, commentArchive, commentCreate, commentsList, commentUpdate, commitAmend, commitCherryPick, commitConflicts, commitCreate, commitDetailsWithLineStats, commitDiscard, commitDiscardChanges, commitInsertBlank, commitMove, commitMoveChangesBetween, commitReword, commitSquash, commitUncommit, commitUncommitChanges, commitUncommitChangesFromCommits, createReviewComment, currentForgeLogin, deleteAllData, deleteProject, deleteReviewComment, deleteUser, discardWorktreeChanges, forgeCompareBranchUrl, forgeInfo, forgeProvider, forgetBitbucketAccount, forgetGithubAccount, forgetGitlabAccount, getBbUser, getGbConfig, getGhUser, getGlUser, getInitialBranchIntegration, getLoginToken, getRedoTargetSnapshot, getRepoInfo, getReview, getReviewBaseRepoUrl, getReviewMergeStatus, getTerminalOptionsForPlatform, getUndoTargetSnapshot, getUserProfileLocal, getWorkspace, gitTestFetch, gitTestPush, headInfo, initApplicationNamespace, initGithubDeviceOauth, listAvailableReviewTemplates, listBranches, listCiChecks, listCommentReactions, listEditors, listKnownBitbucketAccounts, listKnownGithubAccounts, listKnownGitlabAccounts, listPrograms, listProjectsStateless, listRepoLabels, listReviewComments, listReviewerCandidates, listReviewReactions, listReviews, listReviewsForBranch, listReviewSubmissions, listReviewTimelineEvents, loginAndPersist, mergeReview, moveBranch, openInProgram, openInTerminal, peelRestoreSnapshot, ProgramCategory, publishReview, removeBranch, removeCommentReaction, removeReviewLabel, removeReviewReaction, requestReview, resolveCommitConflictHunks, restoreSnapshotWithKind, reviewApply, reviewTemplate, setGbConfig, setPushRemote, setReviewAutoMerge, setReviewDraftiness, setReviewTemplate, setTargetRefAndInitProject, storeBitbucketApiToken, storeGithubPat, storeGitlabPat, tearOffBranch, treeChangeDiffs, unapplyStack, updateBranchName, updateProfileAndPersist, updateProjectSettings, updateReview, updateReviewComment, updateReviewFooters, warmCiChecksCache, withdrawReviewRequest, workspaceBranchAndAncestorsPush, workspaceCheckout, workspaceFetchFromRemotes, workspaceFetchStatus, workspaceIntegrateUpstream, workspaceTargetCommits, WatcherHandle, ANY_FORK, ANY_FORK_OR_MERGE, ANY_MERGE, askpassInit, askpassSubmitPromptResponse, CHILD, getAppSettings, HORIZ_ANCESTOR, HORIZ_PARENT, HORIZONTAL, LEFT_FORK, LEFT_FORK_ANCESTOR, LEFT_FORK_PARENT, LEFT_MERGE, LEFT_MERGE_ANCESTOR, LEFT_MERGE_PARENT, RIGHT_FORK, RIGHT_FORK_ANCESTOR, RIGHT_FORK_PARENT, RIGHT_MERGE, RIGHT_MERGE_ANCESTOR, RIGHT_MERGE_PARENT, updateFeatureFlags, updateFetch, updateOnboardingComplete, updateReviews, updateTelemetry, updateTelemetryDistinctId, updateUi, VERT_ANCESTOR, VERT_PARENT, VERTICAL, watcherStart } = nativeBinding export { absorb } export { absorptionPlan } export { addCommentReaction } @@ -608,6 +608,7 @@ export { commentsList } export { commentUpdate } export { commitAmend } export { commitCherryPick } +export { commitConflicts } export { commitCreate } export { commitDetailsWithLineStats } export { commitDiscard } @@ -684,6 +685,7 @@ export { removeCommentReaction } export { removeReviewLabel } export { removeReviewReaction } export { requestReview } +export { resolveCommitConflictHunks } export { restoreSnapshotWithKind } export { reviewApply } export { reviewTemplate } diff --git a/packages/but-sdk/src/generated/linear/apiParamNames.d.ts b/packages/but-sdk/src/generated/linear/apiParamNames.d.ts index 8dd9face820..808f799e587 100644 --- a/packages/but-sdk/src/generated/linear/apiParamNames.d.ts +++ b/packages/but-sdk/src/generated/linear/apiParamNames.d.ts @@ -29,6 +29,7 @@ export declare const apiParamNames: { readonly commentsList: readonly ["projectId"]; readonly commitAmend: readonly ["projectId", "commitId", "changes", "changesSource", "dryRun"]; readonly commitCherryPick: readonly ["projectId", "sourceCommitIds", "relativeTo", "side", "dryRun"]; + readonly commitConflicts: readonly ["projectId", "commitId"]; readonly commitCreate: readonly ["projectId", "relativeTo", "side", "changes", "changesSource", "message", "dryRun"]; readonly commitDetailsWithLineStats: readonly ["projectId", "commitId"]; readonly commitDiscard: readonly ["projectId", "subjectCommitId", "dryRun"]; @@ -104,6 +105,7 @@ export declare const apiParamNames: { readonly removeReviewLabel: readonly ["projectId", "reviewId", "label"]; readonly removeReviewReaction: readonly ["projectId", "reviewId", "reactionId"]; readonly requestReview: readonly ["projectId", "reviewId", "logins"]; + readonly resolveCommitConflictHunks: readonly ["projectId", "commitId", "specs"]; readonly restoreSnapshotWithKind: readonly ["projectId", "restoreKind", "sha"]; readonly reviewApply: readonly ["projectId", "reviewId"]; readonly reviewTemplate: readonly ["projectId"]; diff --git a/packages/but-sdk/src/generated/linear/apiParamNames.js b/packages/but-sdk/src/generated/linear/apiParamNames.js index 36ec8cc4e83..6a077431740 100644 --- a/packages/but-sdk/src/generated/linear/apiParamNames.js +++ b/packages/but-sdk/src/generated/linear/apiParamNames.js @@ -29,6 +29,7 @@ export const apiParamNames = { commentsList: ["projectId"], commitAmend: ["projectId", "commitId", "changes", "changesSource", "dryRun"], commitCherryPick: ["projectId", "sourceCommitIds", "relativeTo", "side", "dryRun"], + commitConflicts: ["projectId", "commitId"], commitCreate: ["projectId", "relativeTo", "side", "changes", "changesSource", "message", "dryRun"], commitDetailsWithLineStats: ["projectId", "commitId"], commitDiscard: ["projectId", "subjectCommitId", "dryRun"], @@ -104,6 +105,7 @@ export const apiParamNames = { removeReviewLabel: ["projectId", "reviewId", "label"], removeReviewReaction: ["projectId", "reviewId", "reactionId"], requestReview: ["projectId", "reviewId", "logins"], + resolveCommitConflictHunks: ["projectId", "commitId", "specs"], restoreSnapshotWithKind: ["projectId", "restoreKind", "sha"], reviewApply: ["projectId", "reviewId"], reviewTemplate: ["projectId"], diff --git a/packages/but-sdk/src/generated/linear/index.d.ts b/packages/but-sdk/src/generated/linear/index.d.ts index 4a719e8d795..fc0099b322a 100644 --- a/packages/but-sdk/src/generated/linear/index.d.ts +++ b/packages/but-sdk/src/generated/linear/index.d.ts @@ -263,6 +263,19 @@ export declare function commitAmend(projectId: string, commitId: string, changes */ export declare function commitCherryPick(projectId: string, sourceCommitIds: Array, relativeTo: RelativeTo, side: InsertSide, dryRun: boolean): Promise +/** + * Return the conflicts of the conflicted commit `commit_id` without entering + * edit mode or touching the working tree. + * + * Hunks are identified by `(path, 1-based index)`; the extraction is + * deterministic, so the same commit id always yields the same hunks and + * `resolve_commit_conflict_hunks()` can be called with indices from this + * result. Fails for commits whose conflicts have no hunk representation + * (deletions/renames, binaries, oversized files, marker-like content) — those + * need manual resolution in edit mode. + */ +export declare function commitConflicts(projectId: string, commitId: string): Promise + /** * Insert a new commit built from the `changes` of `changes_source` and record * an oplog snapshot on success. @@ -846,6 +859,21 @@ export declare function removeReviewReaction(projectId: string, reviewId: number /** Request reviews from the given users on a review. */ export declare function requestReview(projectId: string, reviewId: number, logins: Array): Promise +/** + * Apply `specs` to the conflicted commit `commit_id` and rebase descendants. + * + * Resolving a subset of the conflicts rewrites the commit into a conflicted + * commit with only the remaining conflicts; resolving all of them rewrites it + * into a normal commit. Either way the commit id changes — address follow-up + * resolutions to the returned `new_commit`. An oplog snapshot records an undo + * point. Nothing is written if any spec fails validation. + * + * [`HunkResolution::Ai`] specs are sent to the configured LLM first (no + * worktree lock is held during the model call); AI configuration is only + * required when such a spec is present. + */ +export declare function resolveCommitConflictHunks(projectId: string, commitId: string, specs: Array): Promise + /** * Restores the project to a specific snapshot using a specific kind of restore. This operation * also creates a new snapshot in the oplog. @@ -1799,6 +1827,16 @@ export type CommitCherryPickResult = { workspace: WorkspaceState; }; +/** JSON transport type for the conflicts of a conflicted commit. */ +export type CommitConflicts = { + /** The conflicted commit. */ + commitId: string; + /** The conflicted files that decompose into hunks, sorted by path. */ + files: Array; + /** Conflicted files that need manual resolution in edit mode. */ + manual: Array; +}; + /** JSON transport type for creating a commit in the rebase graph. */ export type CommitCreateResult = { /** The new commit if one was created. */ @@ -2717,6 +2755,8 @@ export type HunkResolutionResult = { commitEmptied: boolean; /** The conflicts that remain, per file. */ remaining: Array; + /** Conflicted files that need manual resolution in edit mode. */ + manual: Array; /** Workspace state after the apply. */ workspace: WorkspaceState; }; @@ -2951,6 +2991,18 @@ export type LoginToken = { url: string; }; +/** + * A conflicted file with no hunk representation — a side deletion or rename, a + * non-blob entry, a binary, or one too large to splice — which therefore needs + * manual resolution in edit mode. + */ +export type ManualConflict = { + /** The repo-relative path of the file. */ + path: string; + /** Why it cannot be resolved automatically, for display to the user. */ + reason: string; +}; + /** * An optional full reference name accepted as a string like `refs/heads/main`, * for use as a parameter transport via `#[but_api(...)]`. @@ -3284,6 +3336,19 @@ export type RepoPermissions = { pull: boolean; }; +/** + * One conflict to resolve, addressed by path and 1-based hunk index as + * returned by `commit_conflicts()`. + */ +export type ResolutionSpec = { + /** The repo-relative path of the conflicted file. */ + path: string; + /** The 1-based index of the conflict within the file. */ + hunk: number; + /** How to resolve it. */ + resolution: HunkResolution; +}; + /** How one conflicted file was resolved, for display to the user. */ export type ResolvedFile = { /** The repo-relative path of the file. */ diff --git a/packages/but-sdk/src/generated/linear/index.js b/packages/but-sdk/src/generated/linear/index.js index 791a6987bfd..1530a4094f4 100644 --- a/packages/but-sdk/src/generated/linear/index.js +++ b/packages/but-sdk/src/generated/linear/index.js @@ -579,7 +579,7 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { absorb, absorptionPlan, addCommentReaction, addReviewLabels, addReviewReaction, apply, applyBranchIntegration, assignHunk, branchCannedName, branchCheckout, branchCheckoutNew, branchCreate, branchDetails, branchDiff, branchLand, branchList, branchRemove, branchRename, changesInWorktree, changesInWorktreeWithPerm, checkGithubAuthStatus, checkSigningSettings, commentArchive, commentCreate, commentsList, commentUpdate, commitAmend, commitCherryPick, commitCreate, commitDetailsWithLineStats, commitDiscard, commitDiscardChanges, commitInsertBlank, commitMove, commitMoveChangesBetween, commitReword, commitSquash, commitUncommit, commitUncommitChanges, commitUncommitChangesFromCommits, createReviewComment, currentForgeLogin, deleteAllData, deleteProject, deleteReviewComment, deleteUser, discardWorktreeChanges, forgeCompareBranchUrl, forgeInfo, forgeProvider, forgetBitbucketAccount, forgetGithubAccount, forgetGitlabAccount, getBbUser, getGbConfig, getGhUser, getGlUser, getInitialBranchIntegration, getLoginToken, getRedoTargetSnapshot, getRepoInfo, getReview, getReviewBaseRepoUrl, getReviewMergeStatus, getTerminalOptionsForPlatform, getUndoTargetSnapshot, getUserProfileLocal, getWorkspace, gitTestFetch, gitTestPush, headInfo, initApplicationNamespace, initGithubDeviceOauth, listAvailableReviewTemplates, listBranches, listCiChecks, listCommentReactions, listEditors, listKnownBitbucketAccounts, listKnownGithubAccounts, listKnownGitlabAccounts, listPrograms, listProjectsStateless, listRepoLabels, listReviewComments, listReviewerCandidates, listReviewReactions, listReviews, listReviewsForBranch, listReviewSubmissions, listReviewTimelineEvents, loginAndPersist, mergeReview, moveBranch, openInProgram, openInTerminal, peelRestoreSnapshot, ProgramCategory, publishReview, removeBranch, removeCommentReaction, removeReviewLabel, removeReviewReaction, requestReview, restoreSnapshotWithKind, reviewApply, reviewTemplate, setGbConfig, setPushRemote, setReviewAutoMerge, setReviewDraftiness, setReviewTemplate, setTargetRefAndInitProject, storeBitbucketApiToken, storeGithubPat, storeGitlabPat, tearOffBranch, treeChangeDiffs, unapplyStack, updateBranchName, updateProfileAndPersist, updateProjectSettings, updateReview, updateReviewComment, updateReviewFooters, warmCiChecksCache, withdrawReviewRequest, workspaceBranchAndAncestorsPush, workspaceCheckout, workspaceFetchFromRemotes, workspaceFetchStatus, workspaceIntegrateUpstream, workspaceTargetCommits, WatcherHandle, ANY_FORK, ANY_FORK_OR_MERGE, ANY_MERGE, askpassInit, askpassSubmitPromptResponse, CHILD, getAppSettings, HORIZ_ANCESTOR, HORIZ_PARENT, HORIZONTAL, LEFT_FORK, LEFT_FORK_ANCESTOR, LEFT_FORK_PARENT, LEFT_MERGE, LEFT_MERGE_ANCESTOR, LEFT_MERGE_PARENT, RIGHT_FORK, RIGHT_FORK_ANCESTOR, RIGHT_FORK_PARENT, RIGHT_MERGE, RIGHT_MERGE_ANCESTOR, RIGHT_MERGE_PARENT, updateFeatureFlags, updateFetch, updateOnboardingComplete, updateReviews, updateTelemetry, updateTelemetryDistinctId, updateUi, VERT_ANCESTOR, VERT_PARENT, VERTICAL, watcherStart } = nativeBinding +const { absorb, absorptionPlan, addCommentReaction, addReviewLabels, addReviewReaction, apply, applyBranchIntegration, assignHunk, branchCannedName, branchCheckout, branchCheckoutNew, branchCreate, branchDetails, branchDiff, branchLand, branchList, branchRemove, branchRename, changesInWorktree, changesInWorktreeWithPerm, checkGithubAuthStatus, checkSigningSettings, commentArchive, commentCreate, commentsList, commentUpdate, commitAmend, commitCherryPick, commitConflicts, commitCreate, commitDetailsWithLineStats, commitDiscard, commitDiscardChanges, commitInsertBlank, commitMove, commitMoveChangesBetween, commitReword, commitSquash, commitUncommit, commitUncommitChanges, commitUncommitChangesFromCommits, createReviewComment, currentForgeLogin, deleteAllData, deleteProject, deleteReviewComment, deleteUser, discardWorktreeChanges, forgeCompareBranchUrl, forgeInfo, forgeProvider, forgetBitbucketAccount, forgetGithubAccount, forgetGitlabAccount, getBbUser, getGbConfig, getGhUser, getGlUser, getInitialBranchIntegration, getLoginToken, getRedoTargetSnapshot, getRepoInfo, getReview, getReviewBaseRepoUrl, getReviewMergeStatus, getTerminalOptionsForPlatform, getUndoTargetSnapshot, getUserProfileLocal, getWorkspace, gitTestFetch, gitTestPush, headInfo, initApplicationNamespace, initGithubDeviceOauth, listAvailableReviewTemplates, listBranches, listCiChecks, listCommentReactions, listEditors, listKnownBitbucketAccounts, listKnownGithubAccounts, listKnownGitlabAccounts, listPrograms, listProjectsStateless, listRepoLabels, listReviewComments, listReviewerCandidates, listReviewReactions, listReviews, listReviewsForBranch, listReviewSubmissions, listReviewTimelineEvents, loginAndPersist, mergeReview, moveBranch, openInProgram, openInTerminal, peelRestoreSnapshot, ProgramCategory, publishReview, removeBranch, removeCommentReaction, removeReviewLabel, removeReviewReaction, requestReview, resolveCommitConflictHunks, restoreSnapshotWithKind, reviewApply, reviewTemplate, setGbConfig, setPushRemote, setReviewAutoMerge, setReviewDraftiness, setReviewTemplate, setTargetRefAndInitProject, storeBitbucketApiToken, storeGithubPat, storeGitlabPat, tearOffBranch, treeChangeDiffs, unapplyStack, updateBranchName, updateProfileAndPersist, updateProjectSettings, updateReview, updateReviewComment, updateReviewFooters, warmCiChecksCache, withdrawReviewRequest, workspaceBranchAndAncestorsPush, workspaceCheckout, workspaceFetchFromRemotes, workspaceFetchStatus, workspaceIntegrateUpstream, workspaceTargetCommits, WatcherHandle, ANY_FORK, ANY_FORK_OR_MERGE, ANY_MERGE, askpassInit, askpassSubmitPromptResponse, CHILD, getAppSettings, HORIZ_ANCESTOR, HORIZ_PARENT, HORIZONTAL, LEFT_FORK, LEFT_FORK_ANCESTOR, LEFT_FORK_PARENT, LEFT_MERGE, LEFT_MERGE_ANCESTOR, LEFT_MERGE_PARENT, RIGHT_FORK, RIGHT_FORK_ANCESTOR, RIGHT_FORK_PARENT, RIGHT_MERGE, RIGHT_MERGE_ANCESTOR, RIGHT_MERGE_PARENT, updateFeatureFlags, updateFetch, updateOnboardingComplete, updateReviews, updateTelemetry, updateTelemetryDistinctId, updateUi, VERT_ANCESTOR, VERT_PARENT, VERTICAL, watcherStart } = nativeBinding export { absorb } export { absorptionPlan } export { addCommentReaction } @@ -608,6 +608,7 @@ export { commentsList } export { commentUpdate } export { commitAmend } export { commitCherryPick } +export { commitConflicts } export { commitCreate } export { commitDetailsWithLineStats } export { commitDiscard } @@ -684,6 +685,7 @@ export { removeCommentReaction } export { removeReviewLabel } export { removeReviewReaction } export { requestReview } +export { resolveCommitConflictHunks } export { restoreSnapshotWithKind } export { reviewApply } export { reviewTemplate }