Skip to content

Commit 1ca090d

Browse files
committed
Adds take-side fallback for all conflict types in AI Resolve - #5393
The AI Resolve mode in the Commit Graph WIP details panel could only auto-merge text conflicts; binary, symlink, submodule, file-mode, add/add, and rename conflicts were skipped (or errored) and left as dead-end "needs review" rows with no way to act on them. - Classifies every conflict by kind from its XY status + per-stage file modes/oids, and detects rename/rename, rename/delete, and rename/modify conflicts by reusing the merge-base rename correlation already used for conflict diffs - Labels skipped and errored rows by conflict type and offers inline Take Current / Take Incoming / Delete actions, gated by which side has content to take - Queues a chosen side as a pending resolution that rides the same review-then-Apply lifecycle as AI resolutions (applied on Apply, dropped on Discard) rather than mutating the working tree on click; the rename/rename loser is queued as a pending delete so the panel mirrors exactly what will be applied - Decodes UTF-16/BOM-encoded files in the conflict read path so their conflicts parse and resolve instead of being skipped as marker-less
1 parent a8b0db5 commit 1ca090d

15 files changed

Lines changed: 826 additions & 31 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
1616
- Adds an _Open in Integrated Terminal_ option for worktrees in the _Commit Graph_ and the _Worktrees_ view — opens the selected worktree's folder in the integrated terminal, matching the action already available for repositories and folders ([#5386](https://github.com/gitkraken/vscode-gitlens/issues/5386))
1717
- Adds a _Reveal in Explorer View_ option for working tree (WIP) files in the _Inspect_ and _Commit Graph_ views — right-click a staged, unstaged, or conflicted working file to reveal and select it in VS Code's Explorer view ([#5387](https://github.com/gitkraken/vscode-gitlens/issues/5387))
1818
- Adds _Focus on Branch_, _Focus on Worktree_, and _Solo Branch_ options to the _Commit Graph_ — right-click a branch or worktree in the side bar (or a graph row) to focus the graph and minimap on it, and right-click a working tree (WIP) row to focus or solo the graph on that worktree's branch ([#5388](https://github.com/gitkraken/vscode-gitlens/issues/5388))
19+
- Adds manual take-side fallbacks and conflict-type labels to the AI **Resolve** mode in the _Commit Graph_ WIP details panel — conflicts the AI can't auto-merge (binary, symlink, submodule, file-mode, add/add, and rename/rename or rename/delete conflicts) are now labeled by type and offer inline _Take Current_, _Take Incoming_, and _Delete_ actions instead of dead-ending as "needs review"; resolving a rename/rename conflict also removes the other side's renamed file. Also decodes UTF-16/BOM-encoded files so their conflicts can be resolved rather than skipped ([#5393](https://github.com/gitkraken/vscode-gitlens/issues/5393))
1920

2021
### Changed
2122

packages/git/src/utils/conflictResolution.utils.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,71 @@ import type { GitFileConflictStatus } from '../models/fileStatus.js';
22

33
export type ConflictResolutionAction = 'take-ours' | 'take-theirs' | 'delete' | 'unsupported';
44

5+
/**
6+
* A richer description of a conflict than the raw XY status code — used to label conflicts the
7+
* AI text resolver can't parse (binary, symlink, submodule, mode-only, add/add) and to surface
8+
* rename conflicts. Derived by {@link classifyConflictKind} from the XY status plus, when
9+
* available, the per-stage file modes/oids and rename/binary hints the caller computes.
10+
*/
11+
export type ConflictKind =
12+
| 'text'
13+
| 'binary'
14+
| 'symlink'
15+
| 'submodule'
16+
| 'mode-only'
17+
| 'add-add'
18+
| 'delete-modify'
19+
| 'both-deleted'
20+
| 'rename-rename'
21+
| 'rename-delete'
22+
| 'rename-modify'
23+
| 'unknown';
24+
25+
export type ConflictRenameKind = 'rename-rename' | 'rename-delete' | 'rename-modify';
26+
27+
const symlinkMode = '120000';
28+
const submoduleMode = '160000';
29+
30+
/**
31+
* Classifies a conflict into a {@link ConflictKind}. Pure — all detection that needs git/IO
32+
* (rename correlation, binary sniffing) is computed by the caller and passed via `hints`. When
33+
* `modes`/`oids` are omitted (e.g. a webview caller that only has the XY status), it still returns
34+
* a useful coarse kind; pass the per-stage data for the fine-grained symlink/submodule/mode-only split.
35+
*/
36+
export function classifyConflictKind(
37+
status: GitFileConflictStatus,
38+
modes?: { base?: string; current?: string; incoming?: string },
39+
oids?: { base?: string; current?: string; incoming?: string },
40+
hints?: { binary?: boolean; rename?: ConflictRenameKind },
41+
): ConflictKind {
42+
if (hints?.rename != null) return hints.rename;
43+
44+
if (status === 'DD') return 'both-deleted';
45+
if (status === 'UD' || status === 'DU') return 'delete-modify';
46+
47+
const presentModes = [modes?.current, modes?.incoming, modes?.base].filter((m): m is string => m != null);
48+
if (presentModes.includes(submoduleMode)) return 'submodule';
49+
if (presentModes.includes(symlinkMode)) return 'symlink';
50+
51+
if (hints?.binary) return 'binary';
52+
53+
// Both sides present with identical content (same oid) but different mode → only the file mode
54+
// conflicts (e.g. one side flipped the executable bit).
55+
if (
56+
modes?.current != null &&
57+
modes?.incoming != null &&
58+
modes.current !== modes.incoming &&
59+
oids?.current != null &&
60+
oids.current === oids?.incoming
61+
) {
62+
return 'mode-only';
63+
}
64+
65+
if (status === 'AA' || status === 'AU' || status === 'UA') return 'add-add';
66+
67+
return 'text';
68+
}
69+
570
export function classifyConflictAction(
671
status: GitFileConflictStatus,
772
resolution: 'current' | 'incoming',
@@ -29,3 +94,64 @@ export function canStageCurrent(status: GitFileConflictStatus): boolean {
2994
export function canStageIncoming(status: GitFileConflictStatus): boolean {
3095
return status !== 'AU' && status !== 'DD';
3196
}
97+
98+
/** A short label + one-line description for a {@link ConflictKind}, used to explain conflicts the AI
99+
* resolver can't auto-merge (and rename conflicts) wherever they're surfaced. */
100+
export function getConflictKindLabel(kind: ConflictKind, renameOf?: string): { label: string; description: string } {
101+
const named = renameOf ? `"${renameOf}"` : 'The file';
102+
switch (kind) {
103+
case 'binary':
104+
return {
105+
label: 'Binary conflict',
106+
description: 'Binary file changed on both sides — choose a side to keep',
107+
};
108+
case 'symlink':
109+
return {
110+
label: 'Symlink conflict',
111+
description: 'Symbolic link changed on both sides — choose a side to keep',
112+
};
113+
case 'submodule':
114+
return {
115+
label: 'Submodule conflict',
116+
description: 'Submodule reference changed on both sides — choose a side to keep',
117+
};
118+
case 'mode-only':
119+
return {
120+
label: 'File mode conflict',
121+
description: 'Only the file mode differs (e.g. the executable bit) — choose a side to keep',
122+
};
123+
case 'add-add':
124+
// Covers AA (added on both sides) as well as AU/UA (added on one side) — keep the wording
125+
// accurate for all three rather than asserting "both sides".
126+
return {
127+
label: 'Add conflict',
128+
description: 'Conflicting file additions — choose a side to keep',
129+
};
130+
case 'delete-modify':
131+
return {
132+
label: 'Modified and deleted',
133+
description: 'Deleted on one side and modified on the other — keep the file or delete it',
134+
};
135+
case 'both-deleted':
136+
return { label: 'Deleted on both sides', description: 'Deleted on both sides — confirm the deletion' };
137+
case 'rename-rename':
138+
return {
139+
label: 'Renamed differently',
140+
description: `${named} was renamed differently on each side — choose which name to keep`,
141+
};
142+
case 'rename-delete':
143+
return {
144+
label: 'Renamed and deleted',
145+
description: `${named} was renamed on one side and deleted on the other — keep the file or delete it`,
146+
};
147+
case 'rename-modify':
148+
return {
149+
label: 'Renamed and modified',
150+
description: `${named} was renamed on one side and modified on the other`,
151+
};
152+
case 'text':
153+
return { label: 'Text conflict', description: 'Conflicting changes on both sides' };
154+
default:
155+
return { label: 'Conflict', description: 'Resolve this conflict manually' };
156+
}
157+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import * as assert from 'assert';
2+
import type { GitFile } from '@gitlens/git/models/file.js';
3+
import type { GitFileConflictStatus } from '@gitlens/git/models/fileStatus.js';
4+
import type { GitConflictFile } from '@gitlens/git/models/staging.js';
5+
import { detectRename } from '../conflictKind.utils.js';
6+
7+
function conflictFile(path: string, status: GitFileConflictStatus): GitConflictFile {
8+
const file: GitConflictFile = { path: path, repoPath: '/repo', status: status, conflictStatus: status };
9+
return file;
10+
}
11+
12+
function renamed(originalPath: string, path: string): GitFile {
13+
return { path: path, originalPath: originalPath, status: 'R' };
14+
}
15+
16+
function deleted(path: string): GitFile {
17+
return { path: path, status: 'D' };
18+
}
19+
20+
function modified(path: string): GitFile {
21+
return { path: path, status: 'M' };
22+
}
23+
24+
suite('git/-webview/conflictKind.utils', () => {
25+
suite('detectRename', () => {
26+
test('rename/rename — both sides rename the original to different targets', () => {
27+
// ours-renamed.txt is added-by-us (AU); ours renamed orig→ours-renamed, theirs renamed orig→theirs-renamed.
28+
const result = detectRename(
29+
conflictFile('ours-renamed.txt', 'AU'),
30+
[renamed('orig.txt', 'ours-renamed.txt')],
31+
[renamed('orig.txt', 'theirs-renamed.txt')],
32+
);
33+
assert.deepStrictEqual(result, {
34+
kind: 'rename-rename',
35+
renameOf: 'orig.txt',
36+
renamePairPath: 'theirs-renamed.txt',
37+
});
38+
});
39+
40+
test('rename/delete — one side renames, the other deletes (UD)', () => {
41+
const result = detectRename(
42+
conflictFile('ours-kept.txt', 'UD'),
43+
[renamed('orig.txt', 'ours-kept.txt')],
44+
[deleted('orig.txt')],
45+
);
46+
assert.deepStrictEqual(result, { kind: 'rename-delete', renameOf: 'orig.txt' });
47+
});
48+
49+
test('rename+modify — one side renames, the other modifies content (UU)', () => {
50+
const result = detectRename(
51+
conflictFile('all-renamed.txt', 'UU'),
52+
[renamed('all-rename-modify.txt', 'all-renamed.txt')],
53+
[modified('all-rename-modify.txt')],
54+
);
55+
assert.deepStrictEqual(result, { kind: 'rename-modify', renameOf: 'all-rename-modify.txt' });
56+
});
57+
58+
test('no rename — plain content conflict returns undefined', () => {
59+
const result = detectRename(conflictFile('file.txt', 'UU'), [modified('file.txt')], [modified('file.txt')]);
60+
assert.strictEqual(result, undefined);
61+
});
62+
63+
test('returns undefined when no diff data is available', () => {
64+
assert.strictEqual(detectRename(conflictFile('file.txt', 'UU'), undefined, undefined), undefined);
65+
});
66+
});
67+
});
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import type { GitFile } from '@gitlens/git/models/file.js';
2+
import type { GitFileConflictStatus } from '@gitlens/git/models/fileStatus.js';
3+
import type { GitConflictFile } from '@gitlens/git/models/staging.js';
4+
import type { ConflictKind, ConflictRenameKind } from '@gitlens/git/utils/conflictResolution.utils.js';
5+
import {
6+
canStageCurrent,
7+
canStageIncoming,
8+
classifyConflictKind,
9+
} from '@gitlens/git/utils/conflictResolution.utils.js';
10+
import { getConflictIncomingRef, resolveConflictFilePaths } from '@gitlens/git/utils/pausedOperationStatus.utils.js';
11+
import { getSettledValue } from '@gitlens/utils/promise.js';
12+
import type { GitRepositoryService } from '../../gitRepositoryService.js';
13+
14+
export interface ConflictFileInfo {
15+
readonly path: string;
16+
readonly conflictStatus: GitFileConflictStatus;
17+
readonly kind: ConflictKind;
18+
readonly canStageCurrent: boolean;
19+
readonly canStageIncoming: boolean;
20+
/** Original (pre-rename) path when a rename is involved. */
21+
readonly renameOf?: string;
22+
/** For rename/rename: the other side's target path (the "loser" to remove when taking a side). */
23+
readonly renamePairPath?: string;
24+
}
25+
26+
/**
27+
* Builds a per-path map describing each conflicted file richly enough to label it and offer the
28+
* right take-side actions — the conflict {@link ConflictKind}, which sides can be staged, and any
29+
* rename relationship. Rename detection mirrors `openConflictChanges`: diff the merge-base against
30+
* each side with rename detection on, then correlate via {@link resolveConflictFilePaths} (conflict
31+
* files from `git status` don't carry `originalPath`).
32+
*
33+
* Binary sniffing is intentionally NOT done here (it would cost a working-tree read per file). A
34+
* conflicted file that would otherwise classify as `text` but was skipped by the AI resolver (no
35+
* markers) is binary/unsupported by inference — callers labeling skipped rows should treat a
36+
* `text` kind as `binary`.
37+
*/
38+
export async function getConflictFileInfos(
39+
svc: GitRepositoryService,
40+
conflictFiles?: GitConflictFile[],
41+
): Promise<Map<string, ConflictFileInfo>> {
42+
conflictFiles ??= await svc.status.getConflictingFiles();
43+
44+
const infos = new Map<string, ConflictFileInfo>();
45+
if (!conflictFiles.length) return infos;
46+
47+
// Rename detection needs the merge-base + the incoming ref to diff each side with `-M`. When the
48+
// paused-operation status (or its merge-base) is unavailable, fall back to mode/oid/status-only
49+
// classification — every file still gets a usable kind, just without rename labels.
50+
let currentFiles: GitFile[] | undefined;
51+
let incomingFiles: GitFile[] | undefined;
52+
53+
const pausedStatus = await svc.pausedOps?.getPausedOperationStatus?.();
54+
const mergeBase = pausedStatus?.mergeBase;
55+
if (pausedStatus != null && mergeBase != null) {
56+
const incomingRef = getConflictIncomingRef(pausedStatus) ?? pausedStatus.HEAD.ref;
57+
const [currentResult, incomingResult] = await Promise.allSettled([
58+
svc.diff.getDiffStatus(mergeBase, 'HEAD', { renameLimit: 0 }),
59+
svc.diff.getDiffStatus(mergeBase, incomingRef, { renameLimit: 0 }),
60+
]);
61+
currentFiles = getSettledValue(currentResult);
62+
incomingFiles = getSettledValue(incomingResult);
63+
}
64+
65+
for (const file of conflictFiles) {
66+
const status = file.conflictStatus;
67+
const rename = detectRename(file, currentFiles, incomingFiles);
68+
69+
const kind = classifyConflictKind(
70+
status,
71+
{ base: file.base?.mode, current: file.current?.mode, incoming: file.incoming?.mode },
72+
{ base: file.base?.oid, current: file.current?.oid, incoming: file.incoming?.oid },
73+
{ rename: rename?.kind },
74+
);
75+
76+
infos.set(file.path, {
77+
path: file.path,
78+
conflictStatus: status,
79+
kind: kind,
80+
canStageCurrent: canStageCurrent(status),
81+
canStageIncoming: canStageIncoming(status),
82+
renameOf: rename?.renameOf,
83+
renamePairPath: rename?.renamePairPath,
84+
});
85+
}
86+
87+
return infos;
88+
}
89+
90+
export function detectRename(
91+
file: GitConflictFile,
92+
currentFiles: GitFile[] | undefined,
93+
incomingFiles: GitFile[] | undefined,
94+
): { kind: ConflictRenameKind; renameOf: string; renamePairPath?: string } | undefined {
95+
if (currentFiles == null && incomingFiles == null) return undefined;
96+
97+
const path = file.path;
98+
// `resolveConflictFilePaths` checks a rename on either side and returns the merge-base (original)
99+
// path as `lhsPath`. A differing lhsPath means this conflicted file was renamed by some side.
100+
const { lhsPath } = resolveConflictFilePaths(currentFiles, incomingFiles, path);
101+
if (lhsPath === path) return undefined;
102+
103+
const renameOf = lhsPath;
104+
const status = file.conflictStatus;
105+
106+
// One side renamed, the other deleted the original.
107+
if (status === 'UD' || status === 'DU') return { kind: 'rename-delete', renameOf: renameOf };
108+
109+
// Did both sides rename the same original to (different) targets?
110+
const isRenameTo = (f: GitFile) => (f.status === 'R' || f.status === 'C') && f.originalPath === renameOf;
111+
const currentTargets = (currentFiles ?? []).filter(isRenameTo).map(f => f.path);
112+
const incomingTargets = (incomingFiles ?? []).filter(isRenameTo).map(f => f.path);
113+
114+
if (currentTargets.length && incomingTargets.length) {
115+
const renamePairPath = [...currentTargets, ...incomingTargets].find(p => p !== path);
116+
return { kind: 'rename-rename', renameOf: renameOf, renamePairPath: renamePairPath };
117+
}
118+
119+
// One side renamed, the other modified content at the original path.
120+
return { kind: 'rename-modify', renameOf: renameOf };
121+
}

0 commit comments

Comments
 (0)