diff --git a/CHANGELOG.md b/CHANGELOG.md index 94d4e62101491..ea402282b8176 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: "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 diff --git a/contributions.json b/contributions.json index 41f09ba47af95..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" @@ -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" diff --git a/package.json b/package.json index f4d883efcff20..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)...", @@ -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)...", @@ -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" @@ -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" @@ -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", @@ -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", @@ -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", @@ -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", 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..4c218c9dae932 100644 --- a/src/constants.commands.generated.ts +++ b/src/constants.commands.generated.ts @@ -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' 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` diff --git a/src/env/node/git/sub-providers/graph.ts b/src/env/node/git/sub-providers/graph.ts index 0cd258f46d7e8..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 { 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 { @@ -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' : '' @@ -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, { @@ -366,6 +375,10 @@ export class GraphGitSubProvider implements GitGraphSubProvider { remote: false, upstream: branch?.upstream, }), + mergeBase: mergeBase && { + ...mergeBase, + remote: branchMap.get(mergeBase?.branch)?.remote ?? false, + }, }, }; diff --git a/src/git/utils/-webview/branch.utils.ts b/src/git/utils/-webview/branch.utils.ts index 85126feb45f84..1d89b38fc1984 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); @@ -200,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 f67a4841faf0f..59a8fd09b34f4 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,60 @@ 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) { + 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) { diff --git a/src/webviews/plus/graph/graphWebview.ts b/src/webviews/plus/graph/graphWebview.ts index f3c45cd5cd79e..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'); @@ -3897,6 +3948,25 @@ 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'); diff --git a/src/webviews/plus/graph/protocol.ts b/src/webviews/plus/graph/protocol.ts index 75841fa5d14da..1b7eaef43d6e6 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; + mergeBase?: { commit: string; branch: string; remote: boolean }; } export interface GraphCommitContextValue {