Skip to content

Commit fd7bd43

Browse files
committed
Adds AI recompse 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)
1 parent 75c0b78 commit fd7bd43

File tree

8 files changed

+205
-70
lines changed

8 files changed

+205
-70
lines changed

contributions.json

Lines changed: 26 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"

package.json

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6194,11 +6194,21 @@
61946194
"title": "AI Recompose Branch Commits (Preview)",
61956195
"icon": "$(sparkle)"
61966196
},
6197+
{
6198+
"command": "gitlens.ai.aiRebaseBranch:views",
6199+
"title": "AI Recompose Branch Commits (Preview)",
6200+
"icon": "$(sparkle)"
6201+
},
61976202
{
61986203
"command": "gitlens.ai.aiRebaseUnpushed:graph",
61996204
"title": "AI Recompose Unpushed Commits (Preview)",
62006205
"icon": "$(sparkle)"
62016206
},
6207+
{
6208+
"command": "gitlens.ai.aiRebaseUnpushed:views",
6209+
"title": "AI Recompose Unpushed Commits (Preview)",
6210+
"icon": "$(sparkle)"
6211+
},
62026212
{
62036213
"command": "gitlens.ai.explainBranch",
62046214
"title": "Explain Branch Changes (Preview)...",
@@ -11028,10 +11038,18 @@
1102811038
"command": "gitlens.ai.aiRebaseBranch:graph",
1102911039
"when": "false"
1103011040
},
11041+
{
11042+
"command": "gitlens.ai.aiRebaseBranch:views",
11043+
"when": "false"
11044+
},
1103111045
{
1103211046
"command": "gitlens.ai.aiRebaseUnpushed:graph",
1103311047
"when": "false"
1103411048
},
11049+
{
11050+
"command": "gitlens.ai.aiRebaseUnpushed:views",
11051+
"when": "false"
11052+
},
1103511053
{
1103611054
"command": "gitlens.ai.explainBranch",
1103711055
"when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled"
@@ -17428,6 +17446,16 @@
1742817446
"when": "viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(remote|tracking)\\b)(?!.*?\\b\\+closed\\b)/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && gitlens:repos:withRemotes",
1742917447
"group": "1_gitlens_actions@3"
1743017448
},
17449+
{
17450+
"command": "gitlens.ai.aiRebaseBranch:views",
17451+
"when": "viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+recomposable\\b)/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled",
17452+
"group": "1_gitlens_actions@6"
17453+
},
17454+
{
17455+
"command": "gitlens.ai.aiRebaseUnpushed:views",
17456+
"when": "viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+ahead\\b)/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled",
17457+
"group": "1_gitlens_actions@7"
17458+
},
1743117459
{
1743217460
"command": "gitlens.views.mergeBranchInto",
1743317461
"when": "viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)(?!.*?\\b\\+closed\\b)/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders",

src/constants.commands.generated.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ 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'

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: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,3 +201,85 @@ 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+
*
208+
* Among two type of base branches targetBranch, mergeBaseBranch we select one that:
209+
* - is defined
210+
* - is not the upstream branch (because the upstream is not a valid base and we have another way to search base commit with the upstream)
211+
* - has the most recent common commit
212+
*
213+
* if mergeBase is not defined we try to use defaultBranch
214+
*
215+
* This function consolidates the common logic used in both graph.ts and branchNode.ts
216+
* for determining if a branch is recomposable.
217+
*/
218+
export async function getBranchMergeBaseAndCommonCommit(
219+
container: Container,
220+
branch: GitBranch,
221+
// options?: GetBranchMergeBaseOptions,
222+
): Promise<{ commit: string; branch: string } | undefined> {
223+
if (branch.remote) return undefined;
224+
225+
const isString = Boolean as unknown as (t: string | undefined) => t is string;
226+
227+
try {
228+
const svc = container.git.getRepositoryService(branch.repoPath);
229+
const upstreamName = branch.upstream?.name;
230+
231+
// Get stored merge target configurations
232+
const [targetBranchResult, mergeBaseResult, defaultBranchResult] = await Promise.allSettled([
233+
svc.branches.getStoredMergeTargetBranchName?.(branch.name),
234+
svc.branches.getBaseBranchName?.(branch.name),
235+
getDefaultBranchName(container, branch.repoPath, branch.name),
236+
]);
237+
const targetBranch = getSettledValue(targetBranchResult);
238+
const validTargetBranch = targetBranch && targetBranch !== upstreamName ? targetBranch : undefined;
239+
const mergeBase = getSettledValue(mergeBaseResult) || getSettledValue(defaultBranchResult);
240+
const validMergeBase = mergeBase && mergeBase !== upstreamName ? mergeBase : undefined;
241+
const validTargets = [validTargetBranch, validMergeBase].filter(isString);
242+
if (validTargets.length === 0) return undefined;
243+
244+
return await selectMostRecentMergeBase(branch.name, validTargets, svc);
245+
} catch {
246+
// If we can't determine, assume not recomposable
247+
return undefined;
248+
}
249+
}
250+
251+
/**
252+
* Selects the most recent merge base from multiple target branches.
253+
*
254+
* It gets the merge base for each target, then uses isAncestorOf() to find which one is newest.
255+
*/
256+
async function selectMostRecentMergeBase(
257+
branchName: string,
258+
targets: string[],
259+
svc: ReturnType<typeof Container.prototype.git.getRepositoryService>,
260+
): Promise<{ commit: string; branch: string } | undefined> {
261+
const mergeBaseResults = await Promise.allSettled(
262+
targets.map(async target => {
263+
const commit = await svc.refs.getMergeBase(branchName, target);
264+
return {
265+
commit: commit,
266+
branch: target,
267+
};
268+
}),
269+
);
270+
const mergeBases = mergeBaseResults
271+
.map(result => getSettledValue(result))
272+
.filter((r): r is { commit: string; branch: string } => r?.commit != null);
273+
274+
if (mergeBases.length === 0) return undefined;
275+
276+
let mostRecentMergeBase = mergeBases[0];
277+
for (let i = 1; i < mergeBases.length; i++) {
278+
const isCurrentMoreRecent = await svc.commits.isAncestorOf(mostRecentMergeBase?.commit, mergeBases[i].commit);
279+
if (isCurrentMoreRecent) {
280+
mostRecentMergeBase = mergeBases[i];
281+
}
282+
}
283+
284+
return mostRecentMergeBase;
285+
}

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,

src/views/nodes/branchesNode.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode';
22
import { GitUri } from '../../git/gitUri';
33
import type { GitBranch } from '../../git/models/branch';
44
import type { Repository } from '../../git/models/repository';
5+
import { getBranchMergeBaseAndCommonCommit } from '../../git/utils/-webview/branch.utils';
56
import { getOpenedWorktreesByBranch } from '../../git/utils/-webview/worktree.utils';
67
import { getLocalBranchUpstreamNames } from '../../git/utils/branch.utils';
78
import { makeHierarchical } from '../../system/array';
@@ -65,11 +66,22 @@ export class BranchesNode extends CacheableChildrenViewNode<'branches', ViewsWit
6566
localUpstreamNames = await getLocalBranchUpstreamNames(branches);
6667
}
6768

69+
// Create a map of branch names to their remote status for efficient lookup
70+
const branchRemoteMap = new Map<string, boolean>();
71+
for await (const branch of branches.values()) {
72+
branchRemoteMap.set(branch.name, branch.remote);
73+
}
74+
6875
const branchNodes: BranchNode[] = [];
6976

7077
for await (const branch of branches.values()) {
7178
if (branch.remote && localUpstreamNames?.has(branch.name)) continue;
7279

80+
const mergeBaseResult =
81+
branch && (await getBranchMergeBaseAndCommonCommit(this.view.container, branch));
82+
const isRecomposable = Boolean(mergeBaseResult && mergeBaseResult.commit !== branch?.sha);
83+
const mergeBase = isRecomposable ? mergeBaseResult : undefined;
84+
7385
branchNodes.push(
7486
new BranchNode(
7587
GitUri.fromRepoPath(this.uri.repoPath!, branch.ref),
@@ -85,6 +97,10 @@ export class BranchesNode extends CacheableChildrenViewNode<'branches', ViewsWit
8597
: this.view.config.showBranchComparison,
8698
showStashes: this.view.config.showStashes,
8799
},
100+
mergeBase && {
101+
...mergeBase,
102+
remote: branchRemoteMap.get(mergeBase.branch) ?? false,
103+
},
88104
),
89105
);
90106
}

0 commit comments

Comments
 (0)