Skip to content

Commit 5153fa9

Browse files
committed
Add AI explain and recompose branch actions to Branch Views
Introduces AI-powered branch actions ("Recompose" and "Explain") directly into the branches view, enabling context menu options when branches are recomposable or have unpushed commits. Refactors and centralizes branch recomposability detection to ensure consistent logic across graph and views, improving maintainability and user experience. Enhances discoverability and workflow integration for AI-assisted Git operations. (#4443, #4522)
1 parent bd9cbc5 commit 5153fa9

File tree

9 files changed

+240
-71
lines changed

9 files changed

+240
-71
lines changed

contributions.json

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,19 @@
3636
]
3737
}
3838
},
39+
"gitlens.ai.aiRebaseBranch:views": {
40+
"label": "AI Recompose Branch Commits (Preview)",
41+
"icon": "$(sparkle)",
42+
"menus": {
43+
"view/item/context": [
44+
{
45+
"when": "viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+recomposable\\b)/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled",
46+
"group": "1_gitlens_actions",
47+
"order": 6
48+
}
49+
]
50+
}
51+
},
3952
"gitlens.ai.aiRebaseUnpushed:graph": {
4053
"label": "AI Recompose Unpushed Commits (Preview)",
4154
"icon": "$(sparkle)",
@@ -49,6 +62,19 @@
4962
]
5063
}
5164
},
65+
"gitlens.ai.aiRebaseUnpushed:views": {
66+
"label": "AI Recompose Unpushed Commits (Preview)",
67+
"icon": "$(sparkle)",
68+
"menus": {
69+
"view/item/context": [
70+
{
71+
"when": "viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+ahead\\b)/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled",
72+
"group": "1_gitlens_actions",
73+
"order": 7
74+
}
75+
]
76+
}
77+
},
5278
"gitlens.ai.explainBranch": {
5379
"label": "Explain Branch Changes (Preview)...",
5480
"commandPalette": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled"
@@ -152,6 +178,19 @@
152178
]
153179
}
154180
},
181+
"gitlens.ai.explainUnpushed:views": {
182+
"label": "Explain Unpushed Changes (Preview)",
183+
"icon": "$(sparkle)",
184+
"menus": {
185+
"view/item/context": [
186+
{
187+
"when": "viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+ahead\\b)/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled",
188+
"group": "3_gitlens_ai",
189+
"order": 2
190+
}
191+
]
192+
}
193+
},
155194
"gitlens.ai.explainWip": {
156195
"label": "Explain Working Changes (Preview)...",
157196
"commandPalette": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled"

docs/telemetry-events.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

package.json

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6152,11 +6152,21 @@
61526152
"title": "AI Recompose Branch Commits (Preview)",
61536153
"icon": "$(sparkle)"
61546154
},
6155+
{
6156+
"command": "gitlens.ai.aiRebaseBranch:views",
6157+
"title": "AI Recompose Branch Commits (Preview)",
6158+
"icon": "$(sparkle)"
6159+
},
61556160
{
61566161
"command": "gitlens.ai.aiRebaseUnpushed:graph",
61576162
"title": "AI Recompose Unpushed Commits (Preview)",
61586163
"icon": "$(sparkle)"
61596164
},
6165+
{
6166+
"command": "gitlens.ai.aiRebaseUnpushed:views",
6167+
"title": "AI Recompose Unpushed Commits (Preview)",
6168+
"icon": "$(sparkle)"
6169+
},
61606170
{
61616171
"command": "gitlens.ai.explainBranch",
61626172
"title": "Explain Branch Changes (Preview)...",
@@ -6207,6 +6217,11 @@
62076217
"title": "Explain Unpushed Changes (Preview)",
62086218
"icon": "$(sparkle)"
62096219
},
6220+
{
6221+
"command": "gitlens.ai.explainUnpushed:views",
6222+
"title": "Explain Unpushed Changes (Preview)",
6223+
"icon": "$(sparkle)"
6224+
},
62106225
{
62116226
"command": "gitlens.ai.explainWip",
62126227
"title": "Explain Working Changes (Preview)...",
@@ -10815,10 +10830,18 @@
1081510830
"command": "gitlens.ai.aiRebaseBranch:graph",
1081610831
"when": "false"
1081710832
},
10833+
{
10834+
"command": "gitlens.ai.aiRebaseBranch:views",
10835+
"when": "false"
10836+
},
1081810837
{
1081910838
"command": "gitlens.ai.aiRebaseUnpushed:graph",
1082010839
"when": "false"
1082110840
},
10841+
{
10842+
"command": "gitlens.ai.aiRebaseUnpushed:views",
10843+
"when": "false"
10844+
},
1082210845
{
1082310846
"command": "gitlens.ai.explainBranch",
1082410847
"when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled"
@@ -10859,6 +10882,10 @@
1085910882
"command": "gitlens.ai.explainUnpushed:graph",
1086010883
"when": "false"
1086110884
},
10885+
{
10886+
"command": "gitlens.ai.explainUnpushed:views",
10887+
"when": "false"
10888+
},
1086210889
{
1086310890
"command": "gitlens.ai.explainWip",
1086410891
"when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled"
@@ -17017,6 +17044,16 @@
1701717044
"when": "viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(remote|tracking)\\b)(?!.*?\\b\\+closed\\b)/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && gitlens:repos:withRemotes",
1701817045
"group": "1_gitlens_actions@3"
1701917046
},
17047+
{
17048+
"command": "gitlens.ai.aiRebaseBranch:views",
17049+
"when": "viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+recomposable\\b)/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled",
17050+
"group": "1_gitlens_actions@6"
17051+
},
17052+
{
17053+
"command": "gitlens.ai.aiRebaseUnpushed:views",
17054+
"when": "viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+ahead\\b)/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled",
17055+
"group": "1_gitlens_actions@7"
17056+
},
1702017057
{
1702117058
"command": "gitlens.views.mergeBranchInto",
1702217059
"when": "viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)(?!.*?\\b\\+closed\\b)/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders",
@@ -17107,6 +17144,11 @@
1710717144
"when": "viewItem =~ /gitlens:branch\\b/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled",
1710817145
"group": "3_gitlens_ai@1"
1710917146
},
17147+
{
17148+
"command": "gitlens.ai.explainUnpushed:views",
17149+
"when": "viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+ahead\\b)/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled",
17150+
"group": "3_gitlens_ai@2"
17151+
},
1711017152
{
1711117153
"command": "gitlens.views.openChangedFileDiffsWithMergeBase",
1711217154
"when": "viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)/ && !listMultiSelection",

src/constants.commands.generated.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,17 @@ export type ContributedCommands =
55
| ContributedKeybindingCommands
66
| ContributedPaletteCommands
77
| 'gitlens.ai.aiRebaseBranch:graph'
8+
| 'gitlens.ai.aiRebaseBranch:views'
89
| 'gitlens.ai.aiRebaseUnpushed:graph'
10+
| 'gitlens.ai.aiRebaseUnpushed:views'
911
| 'gitlens.ai.explainBranch:graph'
1012
| 'gitlens.ai.explainBranch:views'
1113
| 'gitlens.ai.explainCommit:graph'
1214
| 'gitlens.ai.explainCommit:views'
1315
| 'gitlens.ai.explainStash:graph'
1416
| 'gitlens.ai.explainStash:views'
1517
| 'gitlens.ai.explainUnpushed:graph'
18+
| 'gitlens.ai.explainUnpushed:views'
1619
| 'gitlens.ai.explainWip:graph'
1720
| 'gitlens.ai.explainWip:views'
1821
| 'gitlens.ai.feedback.helpful'

src/env/node/git/sub-providers/graph.ts

Lines changed: 5 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import {
3030
} from '../../../../git/parsers/logParser';
3131
import type { GitGraphSearch, GitGraphSearchResultData, GitGraphSearchResults } from '../../../../git/search';
3232
import { getSearchQueryComparisonKey, parseSearchQueryCommand } from '../../../../git/search';
33-
import { getDefaultBranchName, isBranchStarred } from '../../../../git/utils/-webview/branch.utils';
33+
import { getBranchMergeBaseAndCommonCommit, isBranchStarred } from '../../../../git/utils/-webview/branch.utils';
3434
import { getRemoteIconUri } from '../../../../git/utils/-webview/icons';
3535
import { groupWorktreesByBranch } from '../../../../git/utils/-webview/worktree.utils';
3636
import {
@@ -349,7 +349,10 @@ export class GraphGitSubProvider implements GitGraphSubProvider {
349349
branchId = branch?.id ?? getBranchId(repoPath, false, tip);
350350

351351
// Check if branch has commits that can be recomposed and get merge base
352-
const mergeBase = await this.getMergeBase(branch, repoPath);
352+
const mergeBaseResult =
353+
branch && (await getBranchMergeBaseAndCommonCommit(this.container, branch));
354+
const isRecomposable = Boolean(mergeBaseResult && mergeBaseResult.commit !== branch?.sha);
355+
const mergeBase = isRecomposable ? mergeBaseResult : undefined;
353356

354357
context = {
355358
webviewItem: `gitlens:branch${head ? '+current' : ''}${
@@ -625,74 +628,6 @@ export class GraphGitSubProvider implements GitGraphSubProvider {
625628
return getCommitsForGraphCore.call(this, defaultLimit, selectSha, undefined, cancellation);
626629
}
627630

628-
private async getMergeBase(
629-
branch: GitBranch | undefined,
630-
repoPath: string,
631-
): Promise<{ commit: string; branch: string; remote: boolean } | undefined> {
632-
if (!branch || branch.remote) return undefined;
633-
634-
try {
635-
const upstreamName = branch.upstream?.name;
636-
const svc = this.container.git.getRepositoryService(repoPath);
637-
638-
// Get stored merge target configurations
639-
const [targetBranchResult, mergeBaseResult, defaultBranchResult] = await Promise.allSettled([
640-
svc.branches.getStoredMergeTargetBranchName?.(branch.name),
641-
svc.branches.getBaseBranchName?.(branch.name),
642-
getDefaultBranchName(this.container, branch.repoPath, branch.name),
643-
]);
644-
const targetBranch = getSettledValue(targetBranchResult);
645-
const validTargetBranch = targetBranch && targetBranch !== upstreamName ? targetBranch : undefined;
646-
const mergeBase = getSettledValue(mergeBaseResult) || getSettledValue(defaultBranchResult);
647-
const validMergeBase = mergeBase && mergeBase !== upstreamName ? mergeBase : undefined;
648-
649-
// Select target with most recent common commit (closest to branch tip)
650-
const validTargets = [validTargetBranch, validMergeBase];
651-
const recentMergeBase = await this.selectMostRecentMergeBase(branch.name, validTargets, svc);
652-
653-
const isRecomposable = Boolean(recentMergeBase && recentMergeBase.commit !== branch.sha);
654-
return isRecomposable ? recentMergeBase : undefined;
655-
} catch {
656-
// If we can't determine, assume not recomposable
657-
return undefined;
658-
}
659-
}
660-
661-
private async selectMostRecentMergeBase(
662-
branchName: string,
663-
targets: (string | undefined)[],
664-
svc: ReturnType<typeof this.container.git.getRepositoryService>,
665-
): Promise<{ commit: string; branch: string; remote: boolean } | undefined> {
666-
const isString = (t: string | undefined): t is string => Boolean(t);
667-
const mergeBaseResults = await Promise.allSettled(
668-
targets.filter(isString).map(async target => {
669-
const commit = await svc.refs.getMergeBase(branchName, target);
670-
return {
671-
commit: commit,
672-
branch: target,
673-
};
674-
}),
675-
);
676-
const mergeBases = mergeBaseResults
677-
.map(result => getSettledValue(result))
678-
.filter((r): r is { commit: string; branch: string; remote: boolean } => isString(r?.commit));
679-
680-
if (mergeBases.length === 0) return undefined;
681-
682-
let mostRecentMergeBase = mergeBases[0];
683-
for (let i = 1; i < mergeBases.length; i++) {
684-
const isCurrentMoreRecent = await svc.commits.isAncestorOf(
685-
mostRecentMergeBase?.commit,
686-
mergeBases[i].commit,
687-
);
688-
if (isCurrentMoreRecent) {
689-
mostRecentMergeBase = mergeBases[i];
690-
}
691-
}
692-
693-
return mostRecentMergeBase;
694-
}
695-
696631
@log<GraphGitSubProvider['searchGraph']>({
697632
args: {
698633
1: s =>

src/git/utils/-webview/branch.utils.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,3 +201,75 @@ export function getStarredBranchIds(container: Container): Set<string> {
201201

202202
return new Set(Object.keys(starred).filter(branchId => starred[branchId] === true));
203203
}
204+
205+
/**
206+
* Gets the merge base for a branch by checking stored merge target configurations.
207+
* This function consolidates the common logic used in both graph.ts and branchNode.ts
208+
* for determining if a branch is recomposable.
209+
*/
210+
export async function getBranchMergeBaseAndCommonCommit(
211+
container: Container,
212+
branch: GitBranch,
213+
// options?: GetBranchMergeBaseOptions,
214+
): Promise<{ commit: string; branch: string } | undefined> {
215+
const isString = Boolean as unknown as (t: string | undefined) => t is string;
216+
if (branch.remote) return undefined;
217+
218+
try {
219+
const svc = container.git.getRepositoryService(branch.repoPath);
220+
const upstreamName = branch.upstream?.name;
221+
222+
// Get stored merge target configurations
223+
const [targetBranchResult, mergeBaseResult, defaultBranchResult] = await Promise.allSettled([
224+
svc.branches.getStoredMergeTargetBranchName?.(branch.name),
225+
svc.branches.getBaseBranchName?.(branch.name),
226+
getDefaultBranchName(container, branch.repoPath, branch.name),
227+
]);
228+
const targetBranch = getSettledValue(targetBranchResult);
229+
const validTargetBranch = targetBranch && targetBranch !== upstreamName ? targetBranch : undefined;
230+
const mergeBase = getSettledValue(mergeBaseResult) || getSettledValue(defaultBranchResult);
231+
const validMergeBase = mergeBase && mergeBase !== upstreamName ? mergeBase : undefined;
232+
const validTargets = [validTargetBranch, validMergeBase].filter(isString);
233+
if (validTargets.length === 0) return undefined;
234+
235+
return await selectMostRecentMergeBase(branch.name, validTargets, svc);
236+
} catch {
237+
// If we can't determine, assume not recomposable
238+
return undefined;
239+
}
240+
}
241+
242+
/**
243+
* Selects the most recent merge base from multiple target branches.
244+
* This is the same logic used in graph.ts selectMostRecentMergeBase method.
245+
*/
246+
async function selectMostRecentMergeBase(
247+
branchName: string,
248+
targets: string[],
249+
svc: ReturnType<typeof Container.prototype.git.getRepositoryService>,
250+
): Promise<{ commit: string; branch: string } | undefined> {
251+
const mergeBaseResults = await Promise.allSettled(
252+
targets.map(async target => {
253+
const commit = await svc.refs.getMergeBase(branchName, target);
254+
return {
255+
commit: commit,
256+
branch: target,
257+
};
258+
}),
259+
);
260+
const mergeBases = mergeBaseResults
261+
.map(result => getSettledValue(result))
262+
.filter((r): r is { commit: string; branch: string } => r?.commit != null);
263+
264+
if (mergeBases.length === 0) return undefined;
265+
266+
let mostRecentMergeBase = mergeBases[0];
267+
for (let i = 1; i < mergeBases.length; i++) {
268+
const isCurrentMoreRecent = await svc.commits.isAncestorOf(mostRecentMergeBase?.commit, mergeBases[i].commit);
269+
if (isCurrentMoreRecent) {
270+
mostRecentMergeBase = mergeBases[i];
271+
}
272+
}
273+
274+
return mostRecentMergeBase;
275+
}

src/views/nodes/branchNode.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ export class BranchNode
8282
// Specifies that the node is shown as a root
8383
public readonly root: boolean,
8484
options?: Partial<Options>,
85+
public readonly mergeBase?: { commit: string; branch: string; remote: boolean },
8586
) {
8687
super('branch', uri, view, parent);
8788

@@ -393,6 +394,7 @@ export class BranchNode
393394
useBaseNameOnly: !(this.view.config.branches?.layout !== 'tree' || this.compacted || this.avoidCompacting),
394395
worktree: this.worktree,
395396
worktreesByBranch: this.context.worktreesByBranch,
397+
isRecomposable: Boolean(this.mergeBase),
396398
});
397399

398400
// TODO@axosoft-ramint Temporary workaround, remove when our git commands work on closed repos.
@@ -518,6 +520,7 @@ export async function getBranchNodeParts(
518520
useBaseNameOnly: boolean;
519521
worktree?: GitWorktree;
520522
worktreesByBranch?: Map<string, GitWorktree>;
523+
isRecomposable?: boolean;
521524
},
522525
): Promise<{
523526
label: string;
@@ -717,6 +720,10 @@ export async function getBranchNodeParts(
717720
iconPath = getBranchIconPath(container, branch);
718721
}
719722

723+
if (options?.isRecomposable) {
724+
contextValue += '+recomposable';
725+
}
726+
720727
return {
721728
label: label,
722729
description: description,

0 commit comments

Comments
 (0)