From c077c678a7ff2b7050a631311fe5e82dad1e6cda Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Thu, 31 Jul 2025 20:13:12 +0200 Subject: [PATCH 01/16] Adds AI-powered explanation for unpushed branch changes on Graph View Also adds more item context for new AI-assisted commands to explain unpushed changes (#4443, #4522) --- contributions.json | 13 ++++++++++++ package.json | 14 +++++++++++++ src/commands/explainBranch.ts | 27 +++++++++++++++++-------- src/constants.commands.generated.ts | 1 + src/env/node/git/sub-providers/graph.ts | 4 +++- src/webviews/plus/graph/graphWebview.ts | 20 ++++++++++++++++++ 6 files changed, 70 insertions(+), 9 deletions(-) diff --git a/contributions.json b/contributions.json index 41f09ba47af95..3896d99122b0a 100644 --- a/contributions.json +++ b/contributions.json @@ -113,6 +113,19 @@ ] } }, + "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.explainWip": { "label": "Explain Working Changes (Preview)...", "commandPalette": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled" diff --git a/package.json b/package.json index f4d883efcff20..95a4806de0c00 100644 --- a/package.json +++ b/package.json @@ -6234,6 +6234,11 @@ "title": "Explain Changes (Preview)", "icon": "$(sparkle)" }, + { + "command": "gitlens.ai.explainUnpushed:graph", + "title": "Explain Unpushed Changes (Preview)", + "icon": "$(sparkle)" + }, { "command": "gitlens.ai.explainWip", "title": "Explain Working Changes (Preview)...", @@ -11040,6 +11045,10 @@ "command": "gitlens.ai.explainStash:views", "when": "false" }, + { + "command": "gitlens.ai.explainUnpushed:graph", + "when": "false" + }, { "command": "gitlens.ai.explainWip", "when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled" @@ -23339,6 +23348,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", diff --git a/src/commands/explainBranch.ts b/src/commands/explainBranch.ts index a3e1269205597..e9a32f1e5968b 100644 --- a/src/commands/explainBranch.ts +++ b/src/commands/explainBranch.ts @@ -16,6 +16,7 @@ import { ExplainCommandBase } from './explainBase'; export interface ExplainBranchCommandArgs extends ExplainBaseArgs { ref?: string; + baseBranch?: string; } @command() @@ -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 diff --git a/src/constants.commands.generated.ts b/src/constants.commands.generated.ts index 2a6f47b1eab86..a2ca9b3797d39 100644 --- a/src/constants.commands.generated.ts +++ b/src/constants.commands.generated.ts @@ -10,6 +10,7 @@ export type ContributedCommands = | 'gitlens.ai.explainCommit:views' | 'gitlens.ai.explainStash:graph' | 'gitlens.ai.explainStash:views' + | 'gitlens.ai.explainUnpushed:graph' | 'gitlens.ai.explainWip:graph' | 'gitlens.ai.explainWip:views' | 'gitlens.ai.feedback.helpful' diff --git a/src/env/node/git/sub-providers/graph.ts b/src/env/node/git/sub-providers/graph.ts index 0cd258f46d7e8..0cfa032f96781 100644 --- a/src/env/node/git/sub-providers/graph.ts +++ b/src/env/node/git/sub-providers/graph.ts @@ -356,7 +356,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' : '' + }`, webviewItemValue: { type: 'branch', ref: createReference(tip, repoPath, { diff --git a/src/webviews/plus/graph/graphWebview.ts b/src/webviews/plus/graph/graphWebview.ts index f3c45cd5cd79e..166213ecff0cf 100644 --- a/src/webviews/plus/graph/graphWebview.ts +++ b/src/webviews/plus/graph/graphWebview.ts @@ -706,6 +706,7 @@ export class GraphWebviewProvider implements WebviewProvider('gitlens.ai.explainBranch', { + repoPath: ref.repoPath, + ref: ref.ref, + baseBranch: ref.upstream.name, + source: { source: 'graph', context: { type: 'branch' } }, + }); + } + + return Promise.resolve(); + } @log() private explainBranch(item?: GraphItemContext) { const ref = this.getGraphItemRef(item, 'branch'); From ec5f3b7dbb22361f671a410e439567da9c6af268 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Fri, 8 Aug 2025 00:35:54 +0200 Subject: [PATCH 02/16] 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) --- contributions.json | 13 +++++++++++++ package.json | 14 ++++++++++++++ src/constants.commands.generated.ts | 1 + src/views/viewCommands.ts | 16 ++++++++++++++++ 4 files changed, 44 insertions(+) diff --git a/contributions.json b/contributions.json index 3896d99122b0a..0c719e05cda0c 100644 --- a/contributions.json +++ b/contributions.json @@ -126,6 +126,19 @@ ] } }, + "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" diff --git a/package.json b/package.json index 95a4806de0c00..2bfb3356185fc 100644 --- a/package.json +++ b/package.json @@ -6239,6 +6239,11 @@ "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)...", @@ -11049,6 +11054,10 @@ "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" @@ -17481,6 +17490,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", diff --git a/src/constants.commands.generated.ts b/src/constants.commands.generated.ts index a2ca9b3797d39..a0b9c95ba6d29 100644 --- a/src/constants.commands.generated.ts +++ b/src/constants.commands.generated.ts @@ -11,6 +11,7 @@ export type ContributedCommands = | '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' diff --git a/src/views/viewCommands.ts b/src/views/viewCommands.ts index f67a4841faf0f..bd05b86281cde 100644 --- a/src/views/viewCommands.ts +++ b/src/views/viewCommands.ts @@ -5,6 +5,7 @@ import type { CreatePullRequestActionContext, OpenPullRequestActionContext } fro import type { DiffWithCommandArgs } from '../commands/diffWith'; import type { DiffWithPreviousCommandArgs } from '../commands/diffWithPrevious'; import type { DiffWithWorkingCommandArgs } from '../commands/diffWithWorking'; +import type { ExplainBranchCommandArgs } from '../commands/explainBranch'; import type { GenerateChangelogCommandArgs } from '../commands/generateChangelog'; import { generateChangelogAndOpenMarkdownDocument } from '../commands/generateChangelog'; import type { GenerateRebaseCommandArgs } from '../commands/generateRebase'; @@ -926,6 +927,21 @@ export class ViewCommands implements Disposable { }); } + @command('gitlens.ai.explainUnpushed:views') + @log() + private async explainUnpushed(node: BranchNode) { + if (!node.is('branch') || !node.branch.upstream) { + return Promise.resolve(); + } + + await executeCommand('gitlens.ai.explainBranch', { + repoPath: node.repoPath, + ref: node.branch.ref, + baseBranch: node.branch.upstream.name, + source: { source: 'view', context: { type: 'branch' } }, + }); + } + @command('gitlens.views.rebaseOntoUpstream') @log() private rebaseToRemote(node: BranchNode | BranchTrackingStatusNode) { From 286b2b7c7583e985e475b6ba2abfd39d05a7def2 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Fri, 8 Aug 2025 16:10:09 +0200 Subject: [PATCH 03/16] Clarifies Git merge base and target config key semantics (#4443, #4522) --- src/constants.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/constants.ts b/src/constants.ts index 1a893ee8bb044..5bbf613ed61bc 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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` From 8095dc5b0c1c6a07de0e2f28ddcc178c871849b3 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Fri, 8 Aug 2025 16:13:59 +0200 Subject: [PATCH 04/16] Updates CHANGELOG by describing new AI powered actions on a branch (#4443, #4522) --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94d4e62101491..b1aec290aea96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: "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 From 30c6f9a00424589c9604f5b6ffd7830b4cf570b9 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Wed, 8 Oct 2025 15:20:24 +0200 Subject: [PATCH 05/16] Prepares space for future AI-powered recompose actions for a branch Introduces new AI-assisted commands to recompose all branch commits and specifically unpushed commits via the graph webview. (#4443) --- contributions.json | 26 +++++++++++++++++++++++ package.json | 28 +++++++++++++++++++++++++ src/constants.commands.generated.ts | 2 ++ src/webviews/plus/graph/graphWebview.ts | 19 +++++++++++++++++ 4 files changed, 75 insertions(+) diff --git a/contributions.json b/contributions.json index 0c719e05cda0c..c544384e17309 100644 --- a/contributions.json +++ b/contributions.json @@ -23,6 +23,32 @@ ] } }, + "gitlens.ai.aiRebaseBranch:graph": { + "label": "AI Recompose branch commits (Preview)...", + "icon": "$(sparkle)", + "menus": { + "webview/context": [ + { + "when": "webviewItem =~ /gitlens:branch\\b/ && !listMultiSelection", + "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.explainBranch": { "label": "Explain Branch Changes (Preview)...", "commandPalette": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled" diff --git a/package.json b/package.json index 2bfb3356185fc..752f92abf1e1c 100644 --- a/package.json +++ b/package.json @@ -6189,6 +6189,16 @@ "category": "GitLens", "icon": "$(person-add)" }, + { + "command": "gitlens.ai.aiRebaseBranch:graph", + "title": "AI Recompose branch commits (Preview)...", + "icon": "$(sparkle)" + }, + { + "command": "gitlens.ai.aiRebaseUnpushed:graph", + "title": "AI Recompose unpushed commits (Preview)...", + "icon": "$(sparkle)" + }, { "command": "gitlens.ai.explainBranch", "title": "Explain Branch Changes (Preview)...", @@ -11014,6 +11024,14 @@ "command": "gitlens.addAuthors", "when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" }, + { + "command": "gitlens.ai.aiRebaseBranch:graph", + "when": "false" + }, + { + "command": "gitlens.ai.aiRebaseUnpushed:graph", + "when": "false" + }, { "command": "gitlens.ai.explainBranch", "when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled" @@ -23287,6 +23305,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/ && !listMultiSelection", + "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", diff --git a/src/constants.commands.generated.ts b/src/constants.commands.generated.ts index a0b9c95ba6d29..cf2ca0ca7b204 100644 --- a/src/constants.commands.generated.ts +++ b/src/constants.commands.generated.ts @@ -4,6 +4,8 @@ export type ContributedCommands = | ContributedKeybindingCommands | ContributedPaletteCommands + | 'gitlens.ai.aiRebaseBranch:graph' + | 'gitlens.ai.aiRebaseUnpushed:graph' | 'gitlens.ai.explainBranch:graph' | 'gitlens.ai.explainBranch:views' | 'gitlens.ai.explainCommit:graph' diff --git a/src/webviews/plus/graph/graphWebview.ts b/src/webviews/plus/graph/graphWebview.ts index 166213ecff0cf..2918eb261607a 100644 --- a/src/webviews/plus/graph/graphWebview.ts +++ b/src/webviews/plus/graph/graphWebview.ts @@ -532,6 +532,8 @@ export class GraphWebviewProvider implements WebviewProvider Date: Fri, 1 Aug 2025 15:56:53 +0200 Subject: [PATCH 06/16] Shows AI rebase option only for recomposable branches Updates branch context detection to add a '+recomposable' flag for branches that have commits eligible for recomposition, determined by checking recent merge bases against stored targets. Adjusts menu conditions so the AI rebase command is shown only when a branch can be recomposed, preventing unnecessary options for branches without suitable commits. (#4443, #4522) --- contributions.json | 2 +- package.json | 2 +- src/env/node/git/sub-providers/graph.ts | 59 ++++++++++++++++++++++++- 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/contributions.json b/contributions.json index c544384e17309..c4f1082179c06 100644 --- a/contributions.json +++ b/contributions.json @@ -29,7 +29,7 @@ "menus": { "webview/context": [ { - "when": "webviewItem =~ /gitlens:branch\\b/ && !listMultiSelection", + "when": "webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+recomposable\\b)/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled", "group": "1_gitlens_actions", "order": 6 } diff --git a/package.json b/package.json index 752f92abf1e1c..748a297a90ec7 100644 --- a/package.json +++ b/package.json @@ -23307,7 +23307,7 @@ }, { "command": "gitlens.ai.aiRebaseBranch:graph", - "when": "webviewItem =~ /gitlens:branch\\b/ && !listMultiSelection", + "when": "webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+recomposable\\b)/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled", "group": "1_gitlens_actions@6" }, { diff --git a/src/env/node/git/sub-providers/graph.ts b/src/env/node/git/sub-providers/graph.ts index 0cfa032f96781..dc2eb85bcdff9 100644 --- a/src/env/node/git/sub-providers/graph.ts +++ b/src/env/node/git/sub-providers/graph.ts @@ -347,6 +347,10 @@ 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 + const recomposable = await this.isBranchRecomposable(branch, repoPath); + context = { webviewItem: `gitlens:branch${head ? '+current' : ''}${ branch?.upstream != null ? '+tracking' : '' @@ -358,7 +362,7 @@ export class GraphGitSubProvider implements GitGraphSubProvider { : '' }${branch?.starred ? '+starred' : ''}${branch?.upstream?.state.ahead ? '+ahead' : ''}${ branch?.upstream?.state.behind ? '+behind' : '' - }`, + }${recomposable ? '+recomposable' : ''}`, webviewItemValue: { type: 'branch', ref: createReference(tip, repoPath, { @@ -617,6 +621,59 @@ export class GraphGitSubProvider implements GitGraphSubProvider { return getCommitsForGraphCore.call(this, defaultLimit, selectSha, undefined, cancellation); } + private async isBranchRecomposable(branch: GitBranch | undefined, repoPath: string): Promise { + if (!branch || branch.remote) return false; + + try { + const upstreamName = branch.upstream?.name; + const svc = this.container.git.getRepositoryService(repoPath); + + // Get stored merge target configurations + const [storedTargetResult, storedMergeBaseResult] = await Promise.allSettled([ + svc.branches.getStoredMergeTargetBranchName?.(branch.name), + svc.branches.getBaseBranchName?.(branch.name), + ]); + const storedTarget = getSettledValue(storedTargetResult); + const validStoredTarget = storedTarget && storedTarget !== upstreamName ? storedTarget : undefined; + const storedMergeBase = getSettledValue(storedMergeBaseResult); + const validStoredMergeBase = + storedMergeBase && storedMergeBase !== upstreamName ? storedMergeBase : undefined; + + // Select target with most recent common commit (closest to branch tip) + const validTargets = [validStoredTarget, validStoredMergeBase]; + const targetCommit = await this.selectMostRecentMergeBase(branch.name, validTargets, svc); + + return Boolean(targetCommit && targetCommit !== branch.sha); + } catch { + // If we can't determine, assume not recomposable + return false; + } + } + + private async selectMostRecentMergeBase( + branchName: string, + targets: (string | undefined)[], + svc: ReturnType, + ): Promise { + const mergeBaseResults = await Promise.allSettled( + targets.map(target => target && svc.refs.getMergeBase(branchName, target)), + ); + const isString = (t: string | undefined): t is string => Boolean(t); + const mergeBases = mergeBaseResults.map(result => getSettledValue(result)).filter(isString); + + if (mergeBases.length === 0) return undefined; + + let mostRecentMergeBase = mergeBases[0]; + for (let i = 1; i < mergeBases.length; i++) { + const isCurrentMoreRecent = await svc.commits.isAncestorOf(mostRecentMergeBase, mergeBases[i]); + if (isCurrentMoreRecent) { + mostRecentMergeBase = mergeBases[i]; + } + } + + return mostRecentMergeBase; + } + @log({ args: { 1: s => From 43aca18b998519c2334b9e3387aa23b6c299b4d5 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Fri, 1 Aug 2025 19:34:37 +0200 Subject: [PATCH 07/16] Triggers an AI-powered rebase over target branch (#4443, #4522) --- src/env/node/git/sub-providers/graph.ts | 16 +++++++++------- src/webviews/plus/graph/graphWebview.ts | 12 ++++++++++++ src/webviews/plus/graph/protocol.ts | 1 + 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/env/node/git/sub-providers/graph.ts b/src/env/node/git/sub-providers/graph.ts index dc2eb85bcdff9..7d10192d8dd53 100644 --- a/src/env/node/git/sub-providers/graph.ts +++ b/src/env/node/git/sub-providers/graph.ts @@ -348,8 +348,8 @@ 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 - const recomposable = await this.isBranchRecomposable(branch, repoPath); + // Check if branch has commits that can be recomposed and get merge base + const mergeBaseCommit = await this.getMergeBaseCommit(branch, repoPath); context = { webviewItem: `gitlens:branch${head ? '+current' : ''}${ @@ -362,7 +362,7 @@ export class GraphGitSubProvider implements GitGraphSubProvider { : '' }${branch?.starred ? '+starred' : ''}${branch?.upstream?.state.ahead ? '+ahead' : ''}${ branch?.upstream?.state.behind ? '+behind' : '' - }${recomposable ? '+recomposable' : ''}`, + }${mergeBaseCommit ? '+recomposable' : ''}`, webviewItemValue: { type: 'branch', ref: createReference(tip, repoPath, { @@ -372,6 +372,7 @@ export class GraphGitSubProvider implements GitGraphSubProvider { remote: false, upstream: branch?.upstream, }), + mergeBaseCommit: mergeBaseCommit, }, }; @@ -621,8 +622,8 @@ export class GraphGitSubProvider implements GitGraphSubProvider { return getCommitsForGraphCore.call(this, defaultLimit, selectSha, undefined, cancellation); } - private async isBranchRecomposable(branch: GitBranch | undefined, repoPath: string): Promise { - if (!branch || branch.remote) return false; + private async getMergeBaseCommit(branch: GitBranch | undefined, repoPath: string): Promise { + if (!branch || branch.remote) return undefined; try { const upstreamName = branch.upstream?.name; @@ -643,10 +644,11 @@ export class GraphGitSubProvider implements GitGraphSubProvider { const validTargets = [validStoredTarget, validStoredMergeBase]; const targetCommit = await this.selectMostRecentMergeBase(branch.name, validTargets, svc); - return Boolean(targetCommit && targetCommit !== branch.sha); + const isRecomposable = Boolean(targetCommit && targetCommit !== branch.sha); + return isRecomposable ? targetCommit : undefined; } catch { // If we can't determine, assume not recomposable - return false; + return undefined; } } diff --git a/src/webviews/plus/graph/graphWebview.ts b/src/webviews/plus/graph/graphWebview.ts index 2918eb261607a..51f8fdd47e8b7 100644 --- a/src/webviews/plus/graph/graphWebview.ts +++ b/src/webviews/plus/graph/graphWebview.ts @@ -3280,6 +3280,18 @@ export class GraphWebviewProvider implements WebviewProvider('gitlens.ai.generateRebase', { + repoPath: ref.repoPath, + head: ref, + base: createReference(mergeBaseCommit, ref.repoPath, { refType: 'revision' }), + source: { source: 'graph' }, + }); } return Promise.resolve(); diff --git a/src/webviews/plus/graph/protocol.ts b/src/webviews/plus/graph/protocol.ts index 75841fa5d14da..2bd280dab331d 100644 --- a/src/webviews/plus/graph/protocol.ts +++ b/src/webviews/plus/graph/protocol.ts @@ -570,6 +570,7 @@ export interface GraphIssueContextValue { export interface GraphBranchContextValue { type: 'branch'; ref: GitBranchReference; + mergeBaseCommit?: string; } export interface GraphCommitContextValue { From 6e4d0d2bad056a5395a18df8c00e8db8bc269b03 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Mon, 4 Aug 2025 14:58:14 +0200 Subject: [PATCH 08/16] Triggers an AI-powered rebase for unpushed changes (#4443, #4522) --- src/webviews/plus/graph/graphWebview.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/webviews/plus/graph/graphWebview.ts b/src/webviews/plus/graph/graphWebview.ts index 51f8fdd47e8b7..2e1275d684bf0 100644 --- a/src/webviews/plus/graph/graphWebview.ts +++ b/src/webviews/plus/graph/graphWebview.ts @@ -3301,6 +3301,21 @@ export class GraphWebviewProvider implements WebviewProvider('gitlens.ai.generateRebase', { + repoPath: ref.repoPath, + head: ref, + base: createReference(ref.upstream.name, ref.repoPath, { + refType: 'branch', + name: ref.upstream.name, + remote: true, + }), + source: { source: 'graph' }, + }); } return Promise.resolve(); From ba61eeb3e0ef20b2b38501d25f054be2b383a5ea Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Mon, 4 Aug 2025 16:30:17 +0200 Subject: [PATCH 09/16] Rebases branch over its target rather than over common commit. (#4443, #4522) --- src/env/node/git/sub-providers/graph.ts | 40 +++++++++++++++++-------- src/webviews/plus/graph/graphWebview.ts | 10 +++++-- src/webviews/plus/graph/protocol.ts | 2 +- 3 files changed, 36 insertions(+), 16 deletions(-) diff --git a/src/env/node/git/sub-providers/graph.ts b/src/env/node/git/sub-providers/graph.ts index 7d10192d8dd53..60ad73278f342 100644 --- a/src/env/node/git/sub-providers/graph.ts +++ b/src/env/node/git/sub-providers/graph.ts @@ -349,7 +349,7 @@ export class GraphGitSubProvider implements GitGraphSubProvider { branchId = branch?.id ?? getBranchId(repoPath, false, tip); // Check if branch has commits that can be recomposed and get merge base - const mergeBaseCommit = await this.getMergeBaseCommit(branch, repoPath); + const mergeBase = await this.getMergeBase(branch, repoPath); context = { webviewItem: `gitlens:branch${head ? '+current' : ''}${ @@ -362,7 +362,7 @@ export class GraphGitSubProvider implements GitGraphSubProvider { : '' }${branch?.starred ? '+starred' : ''}${branch?.upstream?.state.ahead ? '+ahead' : ''}${ branch?.upstream?.state.behind ? '+behind' : '' - }${mergeBaseCommit ? '+recomposable' : ''}`, + }${mergeBase?.commit ? '+recomposable' : ''}`, webviewItemValue: { type: 'branch', ref: createReference(tip, repoPath, { @@ -372,7 +372,9 @@ export class GraphGitSubProvider implements GitGraphSubProvider { remote: false, upstream: branch?.upstream, }), - mergeBaseCommit: mergeBaseCommit, + mergeBase: mergeBase && { + ...mergeBase, + }, }, }; @@ -622,7 +624,10 @@ export class GraphGitSubProvider implements GitGraphSubProvider { return getCommitsForGraphCore.call(this, defaultLimit, selectSha, undefined, cancellation); } - private async getMergeBaseCommit(branch: GitBranch | undefined, repoPath: string): Promise { + private async getMergeBase( + branch: GitBranch | undefined, + repoPath: string, + ): Promise<{ commit: string; branch: string; remote: boolean } | undefined> { if (!branch || branch.remote) return undefined; try { @@ -642,10 +647,10 @@ export class GraphGitSubProvider implements GitGraphSubProvider { // Select target with most recent common commit (closest to branch tip) const validTargets = [validStoredTarget, validStoredMergeBase]; - const targetCommit = await this.selectMostRecentMergeBase(branch.name, validTargets, svc); + const mergeBase = await this.selectMostRecentMergeBase(branch.name, validTargets, svc); - const isRecomposable = Boolean(targetCommit && targetCommit !== branch.sha); - return isRecomposable ? targetCommit : undefined; + const isRecomposable = Boolean(mergeBase && mergeBase.commit !== branch.sha); + return isRecomposable ? mergeBase : undefined; } catch { // If we can't determine, assume not recomposable return undefined; @@ -656,18 +661,29 @@ export class GraphGitSubProvider implements GitGraphSubProvider { branchName: string, targets: (string | undefined)[], svc: ReturnType, - ): Promise { + ): Promise<{ commit: string; branch: string; remote: boolean } | undefined> { + const isString = (t: string | undefined): t is string => Boolean(t); const mergeBaseResults = await Promise.allSettled( - targets.map(target => target && svc.refs.getMergeBase(branchName, target)), + targets.filter(isString).map(async target => { + const commit = await svc.refs.getMergeBase(branchName, target); + return { + commit: commit, + branch: target, + }; + }), ); - const isString = (t: string | undefined): t is string => Boolean(t); - const mergeBases = mergeBaseResults.map(result => getSettledValue(result)).filter(isString); + const mergeBases = mergeBaseResults + .map(result => getSettledValue(result)) + .filter((r): r is { commit: string; branch: string; remote: boolean } => isString(r?.commit)); if (mergeBases.length === 0) return undefined; let mostRecentMergeBase = mergeBases[0]; for (let i = 1; i < mergeBases.length; i++) { - const isCurrentMoreRecent = await svc.commits.isAncestorOf(mostRecentMergeBase, mergeBases[i]); + const isCurrentMoreRecent = await svc.commits.isAncestorOf( + mostRecentMergeBase?.commit, + mergeBases[i].commit, + ); if (isCurrentMoreRecent) { mostRecentMergeBase = mergeBases[i]; } diff --git a/src/webviews/plus/graph/graphWebview.ts b/src/webviews/plus/graph/graphWebview.ts index 2e1275d684bf0..d26ea9fe7a3c2 100644 --- a/src/webviews/plus/graph/graphWebview.ts +++ b/src/webviews/plus/graph/graphWebview.ts @@ -3280,16 +3280,20 @@ export class GraphWebviewProvider implements WebviewProvider('gitlens.ai.generateRebase', { repoPath: ref.repoPath, head: ref, - base: createReference(mergeBaseCommit, ref.repoPath, { refType: 'revision' }), + base: createReference(mergeBase.branch, ref.repoPath, { + refType: 'branch', + name: mergeBase.branch, + remote: false, + }), source: { source: 'graph' }, }); } diff --git a/src/webviews/plus/graph/protocol.ts b/src/webviews/plus/graph/protocol.ts index 2bd280dab331d..3433dcd2416cb 100644 --- a/src/webviews/plus/graph/protocol.ts +++ b/src/webviews/plus/graph/protocol.ts @@ -570,7 +570,7 @@ export interface GraphIssueContextValue { export interface GraphBranchContextValue { type: 'branch'; ref: GitBranchReference; - mergeBaseCommit?: string; + mergeBase?: { commit: string; branch: string }; } export interface GraphCommitContextValue { From 09a06cf8b5b61c76edfcea6546742856b6298b46 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Mon, 4 Aug 2025 16:33:57 +0200 Subject: [PATCH 10/16] Includes remote flag in merge base branch context (#4443, #4522) --- src/env/node/git/sub-providers/graph.ts | 1 + src/webviews/plus/graph/graphWebview.ts | 2 +- src/webviews/plus/graph/protocol.ts | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/env/node/git/sub-providers/graph.ts b/src/env/node/git/sub-providers/graph.ts index 60ad73278f342..7747fdd6a0015 100644 --- a/src/env/node/git/sub-providers/graph.ts +++ b/src/env/node/git/sub-providers/graph.ts @@ -374,6 +374,7 @@ export class GraphGitSubProvider implements GitGraphSubProvider { }), mergeBase: mergeBase && { ...mergeBase, + remote: branchMap.get(mergeBase?.branch)?.remote ?? false, }, }, }; diff --git a/src/webviews/plus/graph/graphWebview.ts b/src/webviews/plus/graph/graphWebview.ts index d26ea9fe7a3c2..2333c5f7c325a 100644 --- a/src/webviews/plus/graph/graphWebview.ts +++ b/src/webviews/plus/graph/graphWebview.ts @@ -3292,7 +3292,7 @@ export class GraphWebviewProvider implements WebviewProvider Date: Wed, 6 Aug 2025 23:53:41 +0200 Subject: [PATCH 11/16] Falls back to default branch in graph.getMergeBase (#4443, #4522) --- src/env/node/git/sub-providers/graph.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/env/node/git/sub-providers/graph.ts b/src/env/node/git/sub-providers/graph.ts index 7747fdd6a0015..7ca80910b1136 100644 --- a/src/env/node/git/sub-providers/graph.ts +++ b/src/env/node/git/sub-providers/graph.ts @@ -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 { getDefaultBranchName, isBranchStarred } from '../../../../git/utils/-webview/branch.utils'; import { getRemoteIconUri } from '../../../../git/utils/-webview/icons'; import { groupWorktreesByBranch } from '../../../../git/utils/-webview/worktree.utils'; import { @@ -636,22 +636,22 @@ export class GraphGitSubProvider implements GitGraphSubProvider { const svc = this.container.git.getRepositoryService(repoPath); // Get stored merge target configurations - const [storedTargetResult, storedMergeBaseResult] = await Promise.allSettled([ + const [targetBranchResult, mergeBaseResult, defaultBranchResult] = await Promise.allSettled([ svc.branches.getStoredMergeTargetBranchName?.(branch.name), svc.branches.getBaseBranchName?.(branch.name), + getDefaultBranchName(this.container, branch.repoPath, branch.name), ]); - const storedTarget = getSettledValue(storedTargetResult); - const validStoredTarget = storedTarget && storedTarget !== upstreamName ? storedTarget : undefined; - const storedMergeBase = getSettledValue(storedMergeBaseResult); - const validStoredMergeBase = - storedMergeBase && storedMergeBase !== upstreamName ? storedMergeBase : undefined; + const targetBranch = getSettledValue(targetBranchResult); + const validTargetBranch = targetBranch && targetBranch !== upstreamName ? targetBranch : undefined; + const mergeBase = getSettledValue(mergeBaseResult) || getSettledValue(defaultBranchResult); + const validMergeBase = mergeBase && mergeBase !== upstreamName ? mergeBase : undefined; // Select target with most recent common commit (closest to branch tip) - const validTargets = [validStoredTarget, validStoredMergeBase]; - const mergeBase = await this.selectMostRecentMergeBase(branch.name, validTargets, svc); + const validTargets = [validTargetBranch, validMergeBase]; + const recentMergeBase = await this.selectMostRecentMergeBase(branch.name, validTargets, svc); - const isRecomposable = Boolean(mergeBase && mergeBase.commit !== branch.sha); - return isRecomposable ? mergeBase : undefined; + const isRecomposable = Boolean(recentMergeBase && recentMergeBase.commit !== branch.sha); + return isRecomposable ? recentMergeBase : undefined; } catch { // If we can't determine, assume not recomposable return undefined; From 8d13e4badd0825fc21198071e596eb0c26aa853a Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Wed, 6 Aug 2025 23:56:52 +0200 Subject: [PATCH 12/16] Adds storedMergeTargetBranch in merge target search util function This uses the user defined value first and only when it's not set we fall back to other possible values. (#4443, #4522) --- src/git/utils/-webview/branch.utils.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/git/utils/-webview/branch.utils.ts b/src/git/utils/-webview/branch.utils.ts index 85126feb45f84..5b7fac93ae3a5 100644 --- a/src/git/utils/-webview/branch.utils.ts +++ b/src/git/utils/-webview/branch.utils.ts @@ -88,7 +88,8 @@ export async function getBranchMergeTargetName( }, ): Promise> { async function getMergeTargetFallback() { - const [baseResult, defaultResult] = await Promise.allSettled([ + const [storedBase, baseResult, defaultResult] = await Promise.allSettled([ + container.git.getRepositoryService(branch.repoPath).branches.getStoredMergeTargetBranchName?.(branch.name), container.git .getRepositoryService(branch.repoPath) .branches.getBaseBranchName?.(branch.name, options?.cancellation), @@ -96,7 +97,7 @@ export async function getBranchMergeTargetName( cancellation: options?.cancellation, }), ]); - return getSettledValue(baseResult) ?? getSettledValue(defaultResult); + return getSettledValue(storedBase) ?? getSettledValue(baseResult) ?? getSettledValue(defaultResult); } const result = await getBranchMergeTargetNameWithoutFallback(container, branch, options); From 3799a71d6018a0e637f3446f889d6df85e044fd5 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Wed, 8 Oct 2025 15:14:19 +0200 Subject: [PATCH 13/16] Updates labels of recompose actions --- contributions.json | 4 ++-- package.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/contributions.json b/contributions.json index c4f1082179c06..663244af393d2 100644 --- a/contributions.json +++ b/contributions.json @@ -24,7 +24,7 @@ } }, "gitlens.ai.aiRebaseBranch:graph": { - "label": "AI Recompose branch commits (Preview)...", + "label": "AI Recompose Branch Commits (Preview)", "icon": "$(sparkle)", "menus": { "webview/context": [ @@ -37,7 +37,7 @@ } }, "gitlens.ai.aiRebaseUnpushed:graph": { - "label": "AI Recompose unpushed commits (Preview)...", + "label": "AI Recompose Unpushed Commits (Preview)", "icon": "$(sparkle)", "menus": { "webview/context": [ diff --git a/package.json b/package.json index 748a297a90ec7..f715e6a874053 100644 --- a/package.json +++ b/package.json @@ -6191,12 +6191,12 @@ }, { "command": "gitlens.ai.aiRebaseBranch:graph", - "title": "AI Recompose branch commits (Preview)...", + "title": "AI Recompose Branch Commits (Preview)", "icon": "$(sparkle)" }, { "command": "gitlens.ai.aiRebaseUnpushed:graph", - "title": "AI Recompose unpushed commits (Preview)...", + "title": "AI Recompose Unpushed Commits (Preview)", "icon": "$(sparkle)" }, { From 0166a7d14cc5112126209e510dd56fb80dfd4bff Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Wed, 8 Oct 2025 15:10:52 +0200 Subject: [PATCH 14/16] 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) --- contributions.json | 26 ++++++++ package.json | 28 +++++++++ src/constants.commands.generated.ts | 2 + src/env/node/git/sub-providers/graph.ts | 75 ++-------------------- src/git/utils/-webview/branch.utils.ts | 82 +++++++++++++++++++++++++ src/views/nodes/branchNode.ts | 7 +++ src/views/nodes/branchesNode.ts | 16 +++++ src/views/viewCommands.ts | 39 ++++++++++++ 8 files changed, 205 insertions(+), 70 deletions(-) diff --git a/contributions.json b/contributions.json index 663244af393d2..aa49d1e79c128 100644 --- a/contributions.json +++ b/contributions.json @@ -36,6 +36,19 @@ ] } }, + "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)", @@ -49,6 +62,19 @@ ] } }, + "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" diff --git a/package.json b/package.json index f715e6a874053..169c012733c6d 100644 --- a/package.json +++ b/package.json @@ -6194,11 +6194,21 @@ "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)...", @@ -11028,10 +11038,18 @@ "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" @@ -17428,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", diff --git a/src/constants.commands.generated.ts b/src/constants.commands.generated.ts index cf2ca0ca7b204..4c218c9dae932 100644 --- a/src/constants.commands.generated.ts +++ b/src/constants.commands.generated.ts @@ -5,7 +5,9 @@ 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' diff --git a/src/env/node/git/sub-providers/graph.ts b/src/env/node/git/sub-providers/graph.ts index 7ca80910b1136..a1201b8267a8f 100644 --- a/src/env/node/git/sub-providers/graph.ts +++ b/src/env/node/git/sub-providers/graph.ts @@ -30,7 +30,7 @@ import { } from '../../../../git/parsers/logParser'; import type { GitGraphSearch, GitGraphSearchResultData, GitGraphSearchResults } from '../../../../git/search'; import { getSearchQueryComparisonKey, parseSearchQueryCommand } from '../../../../git/search'; -import { getDefaultBranchName, 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 { @@ -349,7 +349,10 @@ export class GraphGitSubProvider implements GitGraphSubProvider { branchId = branch?.id ?? getBranchId(repoPath, false, tip); // Check if branch has commits that can be recomposed and get merge base - const mergeBase = await this.getMergeBase(branch, repoPath); + 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' : ''}${ @@ -625,74 +628,6 @@ export class GraphGitSubProvider implements GitGraphSubProvider { return getCommitsForGraphCore.call(this, defaultLimit, selectSha, undefined, cancellation); } - private async getMergeBase( - branch: GitBranch | undefined, - repoPath: string, - ): Promise<{ commit: string; branch: string; remote: boolean } | undefined> { - if (!branch || branch.remote) return undefined; - - try { - const upstreamName = branch.upstream?.name; - const svc = this.container.git.getRepositoryService(repoPath); - - // Get stored merge target configurations - const [targetBranchResult, mergeBaseResult, defaultBranchResult] = await Promise.allSettled([ - svc.branches.getStoredMergeTargetBranchName?.(branch.name), - svc.branches.getBaseBranchName?.(branch.name), - getDefaultBranchName(this.container, branch.repoPath, branch.name), - ]); - const targetBranch = getSettledValue(targetBranchResult); - const validTargetBranch = targetBranch && targetBranch !== upstreamName ? targetBranch : undefined; - const mergeBase = getSettledValue(mergeBaseResult) || getSettledValue(defaultBranchResult); - const validMergeBase = mergeBase && mergeBase !== upstreamName ? mergeBase : undefined; - - // Select target with most recent common commit (closest to branch tip) - const validTargets = [validTargetBranch, validMergeBase]; - const recentMergeBase = await this.selectMostRecentMergeBase(branch.name, validTargets, svc); - - const isRecomposable = Boolean(recentMergeBase && recentMergeBase.commit !== branch.sha); - return isRecomposable ? recentMergeBase : undefined; - } catch { - // If we can't determine, assume not recomposable - return undefined; - } - } - - private async selectMostRecentMergeBase( - branchName: string, - targets: (string | undefined)[], - svc: ReturnType, - ): Promise<{ commit: string; branch: string; remote: boolean } | undefined> { - const isString = (t: string | undefined): t is string => Boolean(t); - const mergeBaseResults = await Promise.allSettled( - targets.filter(isString).map(async target => { - const commit = await svc.refs.getMergeBase(branchName, target); - return { - commit: commit, - branch: target, - }; - }), - ); - const mergeBases = mergeBaseResults - .map(result => getSettledValue(result)) - .filter((r): r is { commit: string; branch: string; remote: boolean } => isString(r?.commit)); - - if (mergeBases.length === 0) return undefined; - - let mostRecentMergeBase = mergeBases[0]; - for (let i = 1; i < mergeBases.length; i++) { - const isCurrentMoreRecent = await svc.commits.isAncestorOf( - mostRecentMergeBase?.commit, - mergeBases[i].commit, - ); - if (isCurrentMoreRecent) { - mostRecentMergeBase = mergeBases[i]; - } - } - - return mostRecentMergeBase; - } - @log({ args: { 1: s => diff --git a/src/git/utils/-webview/branch.utils.ts b/src/git/utils/-webview/branch.utils.ts index 5b7fac93ae3a5..1d89b38fc1984 100644 --- a/src/git/utils/-webview/branch.utils.ts +++ b/src/git/utils/-webview/branch.utils.ts @@ -201,3 +201,85 @@ export function getStarredBranchIds(container: Container): Set { return new Set(Object.keys(starred).filter(branchId => starred[branchId] === true)); } + +/** + * Gets the merge base for a branch by checking stored merge target configurations. + * + * Among two type of base branches targetBranch, mergeBaseBranch we select one that: + * - is defined + * - 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) + * - has the most recent common commit + * + * if mergeBase is not defined we try to use defaultBranch + * + * This function consolidates the common logic used in both graph.ts and branchNode.ts + * for determining if a branch is recomposable. + */ +export async function getBranchMergeBaseAndCommonCommit( + container: Container, + branch: GitBranch, + // options?: GetBranchMergeBaseOptions, +): Promise<{ commit: string; branch: string } | undefined> { + if (branch.remote) return undefined; + + const isString = Boolean as unknown as (t: string | undefined) => t is string; + + try { + const svc = container.git.getRepositoryService(branch.repoPath); + const upstreamName = branch.upstream?.name; + + // Get stored merge target configurations + const [targetBranchResult, mergeBaseResult, defaultBranchResult] = await Promise.allSettled([ + svc.branches.getStoredMergeTargetBranchName?.(branch.name), + svc.branches.getBaseBranchName?.(branch.name), + getDefaultBranchName(container, branch.repoPath, branch.name), + ]); + const targetBranch = getSettledValue(targetBranchResult); + const validTargetBranch = targetBranch && targetBranch !== upstreamName ? targetBranch : undefined; + const mergeBase = getSettledValue(mergeBaseResult) || getSettledValue(defaultBranchResult); + const validMergeBase = mergeBase && mergeBase !== upstreamName ? mergeBase : undefined; + const validTargets = [validTargetBranch, validMergeBase].filter(isString); + if (validTargets.length === 0) return undefined; + + return await selectMostRecentMergeBase(branch.name, validTargets, svc); + } catch { + // If we can't determine, assume not recomposable + return undefined; + } +} + +/** + * Selects the most recent merge base from multiple target branches. + * + * It gets the merge base for each target, then uses isAncestorOf() to find which one is newest. + */ +async function selectMostRecentMergeBase( + branchName: string, + targets: string[], + svc: ReturnType, +): Promise<{ commit: string; branch: string } | undefined> { + const mergeBaseResults = await Promise.allSettled( + targets.map(async target => { + const commit = await svc.refs.getMergeBase(branchName, target); + return { + commit: commit, + branch: target, + }; + }), + ); + const mergeBases = mergeBaseResults + .map(result => getSettledValue(result)) + .filter((r): r is { commit: string; branch: string } => r?.commit != null); + + if (mergeBases.length === 0) return undefined; + + let mostRecentMergeBase = mergeBases[0]; + for (let i = 1; i < mergeBases.length; i++) { + const isCurrentMoreRecent = await svc.commits.isAncestorOf(mostRecentMergeBase?.commit, mergeBases[i].commit); + if (isCurrentMoreRecent) { + mostRecentMergeBase = mergeBases[i]; + } + } + + return mostRecentMergeBase; +} diff --git a/src/views/nodes/branchNode.ts b/src/views/nodes/branchNode.ts index 676662d49dc70..77b10cd4085b6 100644 --- a/src/views/nodes/branchNode.ts +++ b/src/views/nodes/branchNode.ts @@ -82,6 +82,7 @@ export class BranchNode // Specifies that the node is shown as a root public readonly root: boolean, options?: Partial, + public readonly mergeBase?: { commit: string; branch: string; remote: boolean }, ) { super('branch', uri, view, parent); @@ -393,6 +394,7 @@ export class BranchNode useBaseNameOnly: !(this.view.config.branches?.layout !== 'tree' || this.compacted || this.avoidCompacting), worktree: this.worktree, worktreesByBranch: this.context.worktreesByBranch, + isRecomposable: Boolean(this.mergeBase), }); // TODO@axosoft-ramint Temporary workaround, remove when our git commands work on closed repos. @@ -518,6 +520,7 @@ export async function getBranchNodeParts( useBaseNameOnly: boolean; worktree?: GitWorktree; worktreesByBranch?: Map; + isRecomposable?: boolean; }, ): Promise<{ label: string; @@ -717,6 +720,10 @@ export async function getBranchNodeParts( iconPath = getBranchIconPath(container, branch); } + if (options?.isRecomposable) { + contextValue += '+recomposable'; + } + return { label: label, description: description, diff --git a/src/views/nodes/branchesNode.ts b/src/views/nodes/branchesNode.ts index 28df1bb404762..72c5637570fe6 100644 --- a/src/views/nodes/branchesNode.ts +++ b/src/views/nodes/branchesNode.ts @@ -2,6 +2,7 @@ import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; import { GitUri } from '../../git/gitUri'; import type { GitBranch } from '../../git/models/branch'; import type { Repository } from '../../git/models/repository'; +import { getBranchMergeBaseAndCommonCommit } from '../../git/utils/-webview/branch.utils'; import { getOpenedWorktreesByBranch } from '../../git/utils/-webview/worktree.utils'; import { getLocalBranchUpstreamNames } from '../../git/utils/branch.utils'; import { makeHierarchical } from '../../system/array'; @@ -65,11 +66,22 @@ export class BranchesNode extends CacheableChildrenViewNode<'branches', ViewsWit localUpstreamNames = await getLocalBranchUpstreamNames(branches); } + // Create a map of branch names to their remote status for efficient lookup + const branchRemoteMap = new Map(); + for await (const branch of branches.values()) { + branchRemoteMap.set(branch.name, branch.remote); + } + const branchNodes: BranchNode[] = []; for await (const branch of branches.values()) { if (branch.remote && localUpstreamNames?.has(branch.name)) continue; + const mergeBaseResult = + branch && (await getBranchMergeBaseAndCommonCommit(this.view.container, branch)); + const isRecomposable = Boolean(mergeBaseResult && mergeBaseResult.commit !== branch?.sha); + const mergeBase = isRecomposable ? mergeBaseResult : undefined; + branchNodes.push( new BranchNode( GitUri.fromRepoPath(this.uri.repoPath!, branch.ref), @@ -85,6 +97,10 @@ export class BranchesNode extends CacheableChildrenViewNode<'branches', ViewsWit : this.view.config.showBranchComparison, showStashes: this.view.config.showStashes, }, + mergeBase && { + ...mergeBase, + remote: branchRemoteMap.get(mergeBase.branch) ?? false, + }, ), ); } diff --git a/src/views/viewCommands.ts b/src/views/viewCommands.ts index bd05b86281cde..59a8fd09b34f4 100644 --- a/src/views/viewCommands.ts +++ b/src/views/viewCommands.ts @@ -927,6 +927,45 @@ export class ViewCommands implements Disposable { }); } + @command('gitlens.ai.aiRebaseBranch:views') + @log() + private async aiRebaseBranch(node: BranchNode) { + const mergeBase = node.is('branch') && node.mergeBase; + if (!mergeBase) { + return Promise.resolve(); + } + + await executeCommand('gitlens.ai.generateRebase', { + repoPath: node.repoPath, + head: node.ref, + base: createReference(mergeBase.branch, node.repoPath, { + refType: 'branch', + name: mergeBase.branch, + remote: mergeBase.remote, + }), + source: { source: 'view', detail: 'branch' }, + }); + } + + @command('gitlens.ai.aiRebaseUnpushed:views') + @log() + private async aiRebaseUnpushed(node: BranchNode) { + if (!node.is('branch') || !node.branch.upstream) { + return Promise.resolve(); + } + + await executeCommand('gitlens.ai.generateRebase', { + repoPath: node.repoPath, + head: node.ref, + base: createReference(node.branch.upstream.name, node.repoPath, { + refType: 'branch', + name: node.branch.upstream.name, + remote: true, + }), + source: { source: 'view', detail: 'branch' }, + }); + } + @command('gitlens.ai.explainUnpushed:views') @log() private async explainUnpushed(node: BranchNode) { From eef96fe693b11a2477676b993f5995b24788c881 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Thu, 2 Oct 2025 13:08:03 +0200 Subject: [PATCH 15/16] Removes AI rebase commands temporary until it is ready in CommitComposer (#4443, #4522) --- contributions.json | 52 ----------------------- package.json | 56 ------------------------- src/constants.commands.generated.ts | 4 -- src/views/nodes/branchNode.ts | 6 --- src/views/viewCommands.ts | 39 ----------------- src/webviews/plus/graph/graphWebview.ts | 50 ---------------------- 6 files changed, 207 deletions(-) diff --git a/contributions.json b/contributions.json index aa49d1e79c128..0c719e05cda0c 100644 --- a/contributions.json +++ b/contributions.json @@ -23,58 +23,6 @@ ] } }, - "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" diff --git a/package.json b/package.json index 169c012733c6d..2bfb3356185fc 100644 --- a/package.json +++ b/package.json @@ -6189,26 +6189,6 @@ "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)...", @@ -11034,22 +11014,6 @@ "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" @@ -17446,16 +17410,6 @@ "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", @@ -23333,16 +23287,6 @@ "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", diff --git a/src/constants.commands.generated.ts b/src/constants.commands.generated.ts index 4c218c9dae932..a0b9c95ba6d29 100644 --- a/src/constants.commands.generated.ts +++ b/src/constants.commands.generated.ts @@ -4,10 +4,6 @@ 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' diff --git a/src/views/nodes/branchNode.ts b/src/views/nodes/branchNode.ts index 77b10cd4085b6..705f1fcb28c1e 100644 --- a/src/views/nodes/branchNode.ts +++ b/src/views/nodes/branchNode.ts @@ -394,7 +394,6 @@ export class BranchNode useBaseNameOnly: !(this.view.config.branches?.layout !== 'tree' || this.compacted || this.avoidCompacting), worktree: this.worktree, worktreesByBranch: this.context.worktreesByBranch, - isRecomposable: Boolean(this.mergeBase), }); // TODO@axosoft-ramint Temporary workaround, remove when our git commands work on closed repos. @@ -520,7 +519,6 @@ export async function getBranchNodeParts( useBaseNameOnly: boolean; worktree?: GitWorktree; worktreesByBranch?: Map; - isRecomposable?: boolean; }, ): Promise<{ label: string; @@ -720,10 +718,6 @@ export async function getBranchNodeParts( iconPath = getBranchIconPath(container, branch); } - if (options?.isRecomposable) { - contextValue += '+recomposable'; - } - return { label: label, description: description, diff --git a/src/views/viewCommands.ts b/src/views/viewCommands.ts index 59a8fd09b34f4..bd05b86281cde 100644 --- a/src/views/viewCommands.ts +++ b/src/views/viewCommands.ts @@ -927,45 +927,6 @@ export class ViewCommands implements Disposable { }); } - @command('gitlens.ai.aiRebaseBranch:views') - @log() - private async aiRebaseBranch(node: BranchNode) { - const mergeBase = node.is('branch') && node.mergeBase; - if (!mergeBase) { - return Promise.resolve(); - } - - await executeCommand('gitlens.ai.generateRebase', { - repoPath: node.repoPath, - head: node.ref, - base: createReference(mergeBase.branch, node.repoPath, { - refType: 'branch', - name: mergeBase.branch, - remote: mergeBase.remote, - }), - source: { source: 'view', detail: 'branch' }, - }); - } - - @command('gitlens.ai.aiRebaseUnpushed:views') - @log() - private async aiRebaseUnpushed(node: BranchNode) { - if (!node.is('branch') || !node.branch.upstream) { - return Promise.resolve(); - } - - await executeCommand('gitlens.ai.generateRebase', { - repoPath: node.repoPath, - head: node.ref, - base: createReference(node.branch.upstream.name, node.repoPath, { - refType: 'branch', - name: node.branch.upstream.name, - remote: true, - }), - source: { source: 'view', detail: 'branch' }, - }); - } - @command('gitlens.ai.explainUnpushed:views') @log() private async explainUnpushed(node: BranchNode) { diff --git a/src/webviews/plus/graph/graphWebview.ts b/src/webviews/plus/graph/graphWebview.ts index 2333c5f7c325a..166213ecff0cf 100644 --- a/src/webviews/plus/graph/graphWebview.ts +++ b/src/webviews/plus/graph/graphWebview.ts @@ -532,8 +532,6 @@ export class GraphWebviewProvider implements WebviewProvider('gitlens.ai.generateRebase', { - repoPath: ref.repoPath, - head: ref, - base: createReference(mergeBase.branch, ref.repoPath, { - refType: 'branch', - name: mergeBase.branch, - remote: mergeBase.remote, - }), - source: { source: 'graph' }, - }); - } - - return Promise.resolve(); - } - - @log() - private aiRebaseUnpushed(item?: GraphItemContext) { - if (isGraphItemRefContext(item, 'branch')) { - const { ref } = item.webviewItemValue; - - if (!ref.upstream) { - return Promise.resolve(); - } - - return executeCommand('gitlens.ai.generateRebase', { - repoPath: ref.repoPath, - head: ref, - base: createReference(ref.upstream.name, ref.repoPath, { - refType: 'branch', - name: ref.upstream.name, - remote: true, - }), - source: { source: 'graph' }, - }); - } - - return Promise.resolve(); - } - @log() private cherryPick(item?: GraphItemContext) { const { selection } = this.getGraphItemRefs(item, 'revision'); From 303ed6fe5ecb5db1def342bf4fa5b843910861ac Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Thu, 2 Oct 2025 14:21:44 +0200 Subject: [PATCH 16/16] Adds AI rebase commands (#4443) --- CHANGELOG.md | 2 +- contributions.json | 52 +++++++++++++++++++++++ package.json | 56 +++++++++++++++++++++++++ src/constants.commands.generated.ts | 4 ++ src/views/nodes/branchNode.ts | 6 +++ src/views/viewCommands.ts | 39 +++++++++++++++++ src/webviews/plus/graph/graphWebview.ts | 50 ++++++++++++++++++++++ 7 files changed, 208 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1aec290aea96..ea402282b8176 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Added -- Adds AI powered operations for a branch: "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)) +- 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 diff --git a/contributions.json b/contributions.json index 0c719e05cda0c..aa49d1e79c128 100644 --- a/contributions.json +++ b/contributions.json @@ -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" diff --git a/package.json b/package.json index 2bfb3356185fc..169c012733c6d 100644 --- a/package.json +++ b/package.json @@ -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)...", @@ -11014,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" @@ -17410,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", @@ -23287,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", diff --git a/src/constants.commands.generated.ts b/src/constants.commands.generated.ts index a0b9c95ba6d29..4c218c9dae932 100644 --- a/src/constants.commands.generated.ts +++ b/src/constants.commands.generated.ts @@ -4,6 +4,10 @@ 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' diff --git a/src/views/nodes/branchNode.ts b/src/views/nodes/branchNode.ts index 705f1fcb28c1e..77b10cd4085b6 100644 --- a/src/views/nodes/branchNode.ts +++ b/src/views/nodes/branchNode.ts @@ -394,6 +394,7 @@ export class BranchNode useBaseNameOnly: !(this.view.config.branches?.layout !== 'tree' || this.compacted || this.avoidCompacting), worktree: this.worktree, worktreesByBranch: this.context.worktreesByBranch, + isRecomposable: Boolean(this.mergeBase), }); // TODO@axosoft-ramint Temporary workaround, remove when our git commands work on closed repos. @@ -519,6 +520,7 @@ export async function getBranchNodeParts( useBaseNameOnly: boolean; worktree?: GitWorktree; worktreesByBranch?: Map; + isRecomposable?: boolean; }, ): Promise<{ label: string; @@ -718,6 +720,10 @@ export async function getBranchNodeParts( iconPath = getBranchIconPath(container, branch); } + if (options?.isRecomposable) { + contextValue += '+recomposable'; + } + return { label: label, description: description, diff --git a/src/views/viewCommands.ts b/src/views/viewCommands.ts index bd05b86281cde..59a8fd09b34f4 100644 --- a/src/views/viewCommands.ts +++ b/src/views/viewCommands.ts @@ -927,6 +927,45 @@ export class ViewCommands implements Disposable { }); } + @command('gitlens.ai.aiRebaseBranch:views') + @log() + private async aiRebaseBranch(node: BranchNode) { + const mergeBase = node.is('branch') && node.mergeBase; + if (!mergeBase) { + return Promise.resolve(); + } + + await executeCommand('gitlens.ai.generateRebase', { + repoPath: node.repoPath, + head: node.ref, + base: createReference(mergeBase.branch, node.repoPath, { + refType: 'branch', + name: mergeBase.branch, + remote: mergeBase.remote, + }), + source: { source: 'view', detail: 'branch' }, + }); + } + + @command('gitlens.ai.aiRebaseUnpushed:views') + @log() + private async aiRebaseUnpushed(node: BranchNode) { + if (!node.is('branch') || !node.branch.upstream) { + return Promise.resolve(); + } + + await executeCommand('gitlens.ai.generateRebase', { + repoPath: node.repoPath, + head: node.ref, + base: createReference(node.branch.upstream.name, node.repoPath, { + refType: 'branch', + name: node.branch.upstream.name, + remote: true, + }), + source: { source: 'view', detail: 'branch' }, + }); + } + @command('gitlens.ai.explainUnpushed:views') @log() private async explainUnpushed(node: BranchNode) { diff --git a/src/webviews/plus/graph/graphWebview.ts b/src/webviews/plus/graph/graphWebview.ts index 166213ecff0cf..2333c5f7c325a 100644 --- a/src/webviews/plus/graph/graphWebview.ts +++ b/src/webviews/plus/graph/graphWebview.ts @@ -532,6 +532,8 @@ export class GraphWebviewProvider implements WebviewProvider('gitlens.ai.generateRebase', { + repoPath: ref.repoPath, + head: ref, + base: createReference(mergeBase.branch, ref.repoPath, { + refType: 'branch', + name: mergeBase.branch, + remote: mergeBase.remote, + }), + source: { source: 'graph' }, + }); + } + + return Promise.resolve(); + } + + @log() + private aiRebaseUnpushed(item?: GraphItemContext) { + if (isGraphItemRefContext(item, 'branch')) { + const { ref } = item.webviewItemValue; + + if (!ref.upstream) { + return Promise.resolve(); + } + + return executeCommand('gitlens.ai.generateRebase', { + repoPath: ref.repoPath, + head: ref, + base: createReference(ref.upstream.name, ref.repoPath, { + refType: 'branch', + name: ref.upstream.name, + remote: true, + }), + source: { source: 'graph' }, + }); + } + + return Promise.resolve(); + } + @log() private cherryPick(item?: GraphItemContext) { const { selection } = this.getGraphItemRefs(item, 'revision');