Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p

## [Unreleased]

### Added

- Adds AI powered operations for a branch: "Recompose branch commits", "Recompose unpushed commits", "Explain Unpushed Changed". They are added to the _Commit Graph_ and views context menu for branches ([#4443](https://github.com/gitkraken/vscode-gitlens/issues/4443))

## [17.6.0] - 2025-10-07

### Added
Expand Down
78 changes: 78 additions & 0 deletions contributions.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,58 @@
]
}
},
"gitlens.ai.aiRebaseBranch:graph": {
"label": "AI Recompose Branch Commits (Preview)",
"icon": "$(sparkle)",
"menus": {
"webview/context": [
{
"when": "webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+recomposable\\b)/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled",
"group": "1_gitlens_actions",
"order": 6
}
]
}
},
"gitlens.ai.aiRebaseBranch:views": {
"label": "AI Recompose Branch Commits (Preview)",
"icon": "$(sparkle)",
"menus": {
"view/item/context": [
{
"when": "viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+recomposable\\b)/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled",
"group": "1_gitlens_actions",
"order": 6
}
]
}
},
"gitlens.ai.aiRebaseUnpushed:graph": {
"label": "AI Recompose Unpushed Commits (Preview)",
"icon": "$(sparkle)",
"menus": {
"webview/context": [
{
"when": "webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+ahead\\b)/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled",
"group": "1_gitlens_actions",
"order": 7
}
]
}
},
"gitlens.ai.aiRebaseUnpushed:views": {
"label": "AI Recompose Unpushed Commits (Preview)",
"icon": "$(sparkle)",
"menus": {
"view/item/context": [
{
"when": "viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+ahead\\b)/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled",
"group": "1_gitlens_actions",
"order": 7
}
]
}
},
"gitlens.ai.explainBranch": {
"label": "Explain Branch Changes (Preview)...",
"commandPalette": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled"
Expand Down Expand Up @@ -113,6 +165,32 @@
]
}
},
"gitlens.ai.explainUnpushed:graph": {
"label": "Explain Unpushed Changes (Preview)",
"icon": "$(sparkle)",
"menus": {
"webview/context": [
{
"when": "webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+ahead\\b)/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled",
"group": "1_gitlens_ai",
"order": 20
}
]
}
},
"gitlens.ai.explainUnpushed:views": {
"label": "Explain Unpushed Changes (Preview)",
"icon": "$(sparkle)",
"menus": {
"view/item/context": [
{
"when": "viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+ahead\\b)/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled",
"group": "1_gitlens_ai",
"order": 20
}
]
}
},
"gitlens.ai.explainWip": {
"label": "Explain Working Changes (Preview)...",
"commandPalette": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled"
Expand Down
84 changes: 84 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6189,6 +6189,26 @@
"category": "GitLens",
"icon": "$(person-add)"
},
{
"command": "gitlens.ai.aiRebaseBranch:graph",
"title": "AI Recompose Branch Commits (Preview)",
"icon": "$(sparkle)"
},
{
"command": "gitlens.ai.aiRebaseBranch:views",
"title": "AI Recompose Branch Commits (Preview)",
"icon": "$(sparkle)"
},
{
"command": "gitlens.ai.aiRebaseUnpushed:graph",
"title": "AI Recompose Unpushed Commits (Preview)",
"icon": "$(sparkle)"
},
{
"command": "gitlens.ai.aiRebaseUnpushed:views",
"title": "AI Recompose Unpushed Commits (Preview)",
"icon": "$(sparkle)"
},
{
"command": "gitlens.ai.explainBranch",
"title": "Explain Branch Changes (Preview)...",
Expand Down Expand Up @@ -6234,6 +6254,16 @@
"title": "Explain Changes (Preview)",
"icon": "$(sparkle)"
},
{
"command": "gitlens.ai.explainUnpushed:graph",
"title": "Explain Unpushed Changes (Preview)",
"icon": "$(sparkle)"
},
{
"command": "gitlens.ai.explainUnpushed:views",
"title": "Explain Unpushed Changes (Preview)",
"icon": "$(sparkle)"
},
{
"command": "gitlens.ai.explainWip",
"title": "Explain Working Changes (Preview)...",
Expand Down Expand Up @@ -11004,6 +11034,22 @@
"command": "gitlens.addAuthors",
"when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders"
},
{
"command": "gitlens.ai.aiRebaseBranch:graph",
"when": "false"
},
{
"command": "gitlens.ai.aiRebaseBranch:views",
"when": "false"
},
{
"command": "gitlens.ai.aiRebaseUnpushed:graph",
"when": "false"
},
{
"command": "gitlens.ai.aiRebaseUnpushed:views",
"when": "false"
},
{
"command": "gitlens.ai.explainBranch",
"when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled"
Expand Down Expand Up @@ -11040,6 +11086,14 @@
"command": "gitlens.ai.explainStash:views",
"when": "false"
},
{
"command": "gitlens.ai.explainUnpushed:graph",
"when": "false"
},
{
"command": "gitlens.ai.explainUnpushed:views",
"when": "false"
},
{
"command": "gitlens.ai.explainWip",
"when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled"
Expand Down Expand Up @@ -17392,6 +17446,16 @@
"when": "viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(remote|tracking)\\b)(?!.*?\\b\\+closed\\b)/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && gitlens:repos:withRemotes",
"group": "1_gitlens_actions@3"
},
{
"command": "gitlens.ai.aiRebaseBranch:views",
"when": "viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+recomposable\\b)/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled",
"group": "1_gitlens_actions@6"
},
{
"command": "gitlens.ai.aiRebaseUnpushed:views",
"when": "viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+ahead\\b)/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled",
"group": "1_gitlens_actions@7"
},
{
"command": "gitlens.views.mergeBranchInto",
"when": "viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)(?!.*?\\b\\+closed\\b)/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders",
Expand Down Expand Up @@ -17472,6 +17536,11 @@
"when": "viewItem =~ /gitlens:branch\\b/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled",
"group": "1_gitlens_ai@10"
},
{
"command": "gitlens.ai.explainUnpushed:views",
"when": "viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+ahead\\b)/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled",
"group": "1_gitlens_ai@20"
},
{
"command": "gitlens.views.openBranchOnRemote",
"when": "viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(tracking|remote)\\b)/ && !listMultiSelection",
Expand Down Expand Up @@ -23264,6 +23333,16 @@
"when": "webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(remote|tracking)\\b)(?!.*?\\b\\+closed\\b)/ && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && gitlens:repos:withRemotes",
"group": "1_gitlens_actions@3"
},
{
"command": "gitlens.ai.aiRebaseBranch:graph",
"when": "webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+recomposable\\b)/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled",
"group": "1_gitlens_actions@6"
},
{
"command": "gitlens.ai.aiRebaseUnpushed:graph",
"when": "webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+ahead\\b)/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled",
"group": "1_gitlens_actions@7"
},
{
"command": "gitlens.graph.mergeBranchInto",
"when": "webviewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)/ && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders",
Expand Down Expand Up @@ -23339,6 +23418,11 @@
"when": "webviewItem =~ /gitlens:branch\\b/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled",
"group": "1_gitlens_ai@10"
},
{
"command": "gitlens.ai.explainUnpushed:graph",
"when": "webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+ahead\\b)/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled",
"group": "1_gitlens_ai@20"
},
{
"command": "gitlens.graph.openBranchOnRemote",
"when": "webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(tracking|remote)\\b)/ && gitlens:repos:withRemotes",
Expand Down
27 changes: 19 additions & 8 deletions src/commands/explainBranch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { ExplainCommandBase } from './explainBase';

export interface ExplainBranchCommandArgs extends ExplainBaseArgs {
ref?: string;
baseBranch?: string;
}

@command()
Expand Down Expand Up @@ -67,15 +68,25 @@ export class ExplainBranchCommand extends ExplainCommandBase {
}

// Clarifying the base branch
const baseBranchNameResult = await getBranchMergeTargetName(this.container, branch);
let baseBranch;
if (!baseBranchNameResult.paused) {
baseBranch = await svc.branches.getBranch(baseBranchNameResult.value);
}

if (!baseBranch) {
void showGenericErrorMessage(`Unable to find the base branch for branch ${branch.name}.`);
return;
if (args.baseBranch) {
// Use the provided base branch
baseBranch = await svc.branches.getBranch(args.baseBranch);
if (!baseBranch) {
void showGenericErrorMessage(`Unable to find the specified base branch: ${args.baseBranch}`);
return;
}
} else {
// Fall back to automatic merge target detection
const baseBranchNameResult = await getBranchMergeTargetName(this.container, branch);
if (!baseBranchNameResult.paused) {
baseBranch = await svc.branches.getBranch(baseBranchNameResult.value);
}

if (!baseBranch) {
void showGenericErrorMessage(`Unable to find the base branch for branch ${branch.name}.`);
return;
}
}

// Get the diff between the branch and its upstream or base
Expand Down
6 changes: 6 additions & 0 deletions src/constants.commands.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,18 @@
export type ContributedCommands =
| ContributedKeybindingCommands
| ContributedPaletteCommands
| 'gitlens.ai.aiRebaseBranch:graph'
| 'gitlens.ai.aiRebaseBranch:views'
| 'gitlens.ai.aiRebaseUnpushed:graph'
| 'gitlens.ai.aiRebaseUnpushed:views'
| 'gitlens.ai.explainBranch:graph'
| 'gitlens.ai.explainBranch:views'
| 'gitlens.ai.explainCommit:graph'
| 'gitlens.ai.explainCommit:views'
| 'gitlens.ai.explainStash:graph'
| 'gitlens.ai.explainStash:views'
| 'gitlens.ai.explainUnpushed:graph'
| 'gitlens.ai.explainUnpushed:views'
| 'gitlens.ai.explainWip:graph'
| 'gitlens.ai.explainWip:views'
| 'gitlens.ai.feedback.helpful'
Expand Down
16 changes: 16 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,22 @@ export const enum CharCode {
z = 122,
}

/**
* `gk-merge-target` means the branch that the current branch is most likely to be merged into, e.g.
* - branch to compare with by default
* - default target for creating a PR
* - etc.
*
* `gk-merge-target-user` — merge target branch explicitly defined by user,
* if it's defined we use this value instead of `gk-merge-target`, but we keep storing `gk-merge-target` value that was determined automatically.
*
* `gk-merge-base` means the branch that the current branch originates from, e.g. what was the base in the moment of creation.
* This value is used for: ... (TODO describe use cases).
*
* `vscode-merge-base` — value determined by VS Code that is used to determine the merge base for the current branch.
* once `gk-merge-base` is determined, we stop using `vscode-merge-base`
*
*/
export type GitConfigKeys =
| `branch.${string}.vscode-merge-base`
| `branch.${string}.gk-merge-base`
Expand Down
17 changes: 15 additions & 2 deletions src/env/node/git/sub-providers/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import {
} from '../../../../git/parsers/logParser';
import type { GitGraphSearch, GitGraphSearchResultData, GitGraphSearchResults } from '../../../../git/search';
import { getSearchQueryComparisonKey, parseSearchQueryCommand } from '../../../../git/search';
import { isBranchStarred } from '../../../../git/utils/-webview/branch.utils';
import { getBranchMergeBaseAndCommonCommit, isBranchStarred } from '../../../../git/utils/-webview/branch.utils';
import { getRemoteIconUri } from '../../../../git/utils/-webview/icons';
import { groupWorktreesByBranch } from '../../../../git/utils/-webview/worktree.utils';
import {
Expand Down Expand Up @@ -347,6 +347,13 @@ export class GraphGitSubProvider implements GitGraphSubProvider {

branch = branchMap.get(tip);
branchId = branch?.id ?? getBranchId(repoPath, false, tip);

// Check if branch has commits that can be recomposed and get merge base
const mergeBaseResult =
branch && (await getBranchMergeBaseAndCommonCommit(this.container, branch));
const isRecomposable = Boolean(mergeBaseResult && mergeBaseResult.commit !== branch?.sha);
const mergeBase = isRecomposable ? mergeBaseResult : undefined;

context = {
webviewItem: `gitlens:branch${head ? '+current' : ''}${
branch?.upstream != null ? '+tracking' : ''
Expand All @@ -356,7 +363,9 @@ export class GraphGitSubProvider implements GitGraphSubProvider {
: branchIdOfMainWorktree === branchId
? '+checkedout'
: ''
}${branch?.starred ? '+starred' : ''}`,
}${branch?.starred ? '+starred' : ''}${branch?.upstream?.state.ahead ? '+ahead' : ''}${
branch?.upstream?.state.behind ? '+behind' : ''
}${mergeBase?.commit ? '+recomposable' : ''}`,
webviewItemValue: {
type: 'branch',
ref: createReference(tip, repoPath, {
Expand All @@ -366,6 +375,10 @@ export class GraphGitSubProvider implements GitGraphSubProvider {
remote: false,
upstream: branch?.upstream,
}),
mergeBase: mergeBase && {
...mergeBase,
remote: branchMap.get(mergeBase?.branch)?.remote ?? false,
},
},
};

Expand Down
Loading