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

Filter by extension

Filter by extension


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

- Adds the ability to search for GitHub Enterprise and GitLab Self-Managed pull requests by URL in the main step of Launchpad
- Adds Ollama and OpenRouter support for GitLens' AI features ([#3311](https://github.com/gitkraken/vscode-gitlens/issues/3311), [#3906](https://github.com/gitkraken/vscode-gitlens/issues/3906))
- Adds the ability to change a branch's merge target in Home view. ([#4224](https://github.com/gitkraken/vscode-gitlens/issues/4224))
- Adds Google Gemini 2.5 Flash (Preview) model, and OpenAI GPT-4.1, GPT-4.1 mini, GPT-4.1 nano, o4 mini, and o3 models for GitLens' AI features ([#4235](https://github.com/gitkraken/vscode-gitlens/issues/4235))
- Adds _Open File at Revision from Remote_ command to open the specific file revision from a remote file URL

Expand All @@ -22,6 +23,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p

### Fixed

- Fixes an error that can occur when retrieving the active repository, such as when the current file is not part of a repository.
- Fixes cache collision between issues and PRs in autolinks ([#4193](https://github.com/gitkraken/vscode-gitlens/issues/4193))
- Fixes incorrect PR Link Across Azure DevOps Projects ([#4207](https://github.com/gitkraken/vscode-gitlens/issues/4207))
- Fixes detail view incorrectly parses GitHub account in commit message ([#3246](https://github.com/gitkraken/vscode-gitlens/issues/3246))
Expand Down
4 changes: 4 additions & 0 deletions contributions.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@
"enablement": "gitlens:enabled && resourceScheme =~ /^(gitlens|pr)$/",
"commandPalette": "!gitlens:hasVirtualFolders && gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/"
},
"gitlens.changeBranchMergeTarget": {
"label": "Change Branch Merge Target",
"commandPalette": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders"
},
"gitlens.clearFileAnnotations": {
"label": "Clear File Annotations",
"icon": "$(gitlens-gitlens-filled)",
Expand Down
8 changes: 8 additions & 0 deletions docs/telemetry-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -1160,6 +1160,14 @@ or
}
```

### home/changeBranchMergeTarget

> Sent when the user starts defining a user-specific merge target branch

```typescript
void
```

### home/command

> Sent when a Home command is executed
Expand Down
9 changes: 9 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6098,6 +6098,11 @@
"icon": "$(folder-opened)",
"enablement": "gitlens:enabled && resourceScheme =~ /^(gitlens|pr)$/"
},
{
"command": "gitlens.changeBranchMergeTarget",
"title": "Change Branch Merge Target",
"category": "GitLens"
},
{
"command": "gitlens.clearFileAnnotations",
"title": "Clear File Annotations",
Expand Down Expand Up @@ -10383,6 +10388,10 @@
"command": "gitlens.browseRepoBeforeRevisionInNewWindow",
"when": "!gitlens:hasVirtualFolders && gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/"
},
{
"command": "gitlens.changeBranchMergeTarget",
"when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders"
},
{
"command": "gitlens.clearFileAnnotations",
"when": "resource in gitlens:tabs:blameable && (gitlens:window:annotated || resource in gitlens:tabs:annotated)"
Expand Down
153 changes: 153 additions & 0 deletions src/commands/changeBranchMergeTarget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import type { CancellationToken } from 'vscode';
import type { Container } from '../container';
import type { GitBranch } from '../git/models/branch';
import type { Repository } from '../git/models/repository';
import { getSettledValue } from '../system/promise';
import type { ViewsWithRepositoryFolders } from '../views/viewBase';
import type { PartialStepState, StepGenerator, StepState } from './quickCommand';
import { endSteps, QuickCommand, StepResultBreak } from './quickCommand';
import { pickBranchStep, pickOrResetBranchStep, pickRepositoryStep } from './quickCommand.steps';

interface Context {
repos: Repository[];
title: string;
associatedView: ViewsWithRepositoryFolders;
}

type InitialState = {
repo: string | Repository;
branch: string;
mergeBranch: string | undefined;
};

type State = {
repo: Repository;
branch: string;
mergeBranch: string | undefined;
};
function assertState(state: PartialStepState<InitialState>): asserts state is StepState<State> {
if (!state.repo || typeof state.repo === 'string') {
throw new Error('Invalid state: repo should be a Repository instance');
}
}

export interface ChangeBranchMergeTargetCommandArgs {
readonly command: 'changeBranchMergeTarget';
state?: Partial<InitialState>;
}

export class ChangeBranchMergeTargetCommand extends QuickCommand {
constructor(container: Container, args?: ChangeBranchMergeTargetCommandArgs) {
super(container, 'changeBranchMergeTarget', 'changeBranchMergeTarget', 'Change Merge Target', {
description: 'Change Merge Target for a branch',
});
let counter = 0;
if (args?.state?.repo) {
counter++;
}
if (args?.state?.branch) {
counter++;
}
this.initialState = {
counter: counter,
...args?.state,
};
}

protected async *steps(state: PartialStepState<InitialState>): StepGenerator {
const context: Context = {
repos: this.container.git.openRepositories,
title: this.title,
associatedView: this.container.views.branches,
};

while (this.canStepsContinue(state)) {
if (state.counter < 1 || !state.repo || typeof state.repo === 'string') {
const result = yield* pickRepositoryStep(state, context);
if (result === StepResultBreak) {
break;
}

state.repo = result;
}

assertState(state);

if (state.counter < 2 || !state.branch) {
const branches = yield* pickBranchStep(state, context, {
picked: state.branch,
placeholder: 'Pick a branch to edit',
filter: (branch: GitBranch) => !branch.remote,
});
if (branches === StepResultBreak) {
continue;
}

state.branch = branches.name;
}

if (!state.mergeBranch) {
state.mergeBranch = await this.container.git
.branches(state.repo.path)
.getBaseBranchName?.(state.branch);
}

const gitBranch = await state.repo.git.branches().getBranch(state.branch);
const detectedMergeTarget = gitBranch && (await getDetectedMergeTarget(this.container, gitBranch));

const result = yield* pickOrResetBranchStep(state, context, {
picked: state.mergeBranch,
placeholder: 'Pick a merge target branch',
filter: (branch: GitBranch) => branch.remote && branch.name !== state.branch,
resetTitle: 'Reset Merge Target',
resetDescription: detectedMergeTarget ? `Reset to "${detectedMergeTarget}"` : '',
});
if (result === StepResultBreak) {
continue;
}
if (state.branch) {
await this.container.git
.branches(state.repo.path)
.setUserMergeTargetBranchName?.(state.branch, result?.name);
}

endSteps(state);
}
}
}

async function getDetectedMergeTarget(
container: Container,
branch: GitBranch,
options?: { cancellation?: CancellationToken },
): Promise<string | undefined> {
const [baseResult, defaultResult, targetResult] = await Promise.allSettled([
container.git.branches(branch.repoPath).getBaseBranchName?.(branch.name),
container.git.branches(branch.repoPath).getDefaultBranchName(branch.getRemoteName()),
container.git.branches(branch.repoPath).getTargetBranchName?.(branch.name),
]);

const baseBranchName = getSettledValue(baseResult);
const defaultBranchName = getSettledValue(defaultResult);
const targetMaybeResult = getSettledValue(targetResult);
const localValue = targetMaybeResult || baseBranchName || defaultBranchName;
if (localValue) {
return localValue;
}

// only if nothing found locally, try value from integration
return getIntegrationDefaultBranchName(container, branch.repoPath, options);
}

async function getIntegrationDefaultBranchName(
container: Container,
repoPath: string,
options?: { cancellation?: CancellationToken },
): Promise<string | undefined> {
const remote = await container.git.remotes(repoPath).getBestRemoteWithIntegration();
if (remote == null) return undefined;

const integration = await remote.getIntegration();
const defaultBranch = await integration?.getDefaultBranch?.(remote.provider.repoDesc, options);
return defaultBranch && `${remote.name}/${defaultBranch?.name}`;
}
89 changes: 88 additions & 1 deletion src/commands/quickCommand.steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -783,6 +783,84 @@ export function* pickBranchStep<
return canPickStepContinue(step, state, selection) ? selection[0].item : StepResultBreak;
}

export function* pickOrResetBranchStep<
State extends PartialStepState & { repo: Repository },
Context extends { repos: Repository[]; showTags?: boolean; title: string },
>(
state: State,
context: Context,
{
filter,
picked,
placeholder,
title,
resetTitle,
resetDescription,
}: {
filter?: (b: GitBranch) => boolean;
picked?: string | string[];
placeholder: string;
title?: string;
resetTitle: string;
resetDescription: string;
},
): StepResultGenerator<GitBranchReference | undefined> {
const items = getBranches(state.repo, {
buttons: [RevealInSideBarQuickInputButton],
filter: filter,
picked: picked,
}).then(branches =>
branches.length === 0
? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)]
: [
createDirectiveQuickPickItem(Directive.Reset, false, {
label: resetTitle,
description: resetDescription,
}),
...branches,
],
);

const resetButton: QuickInputButton = {
iconPath: new ThemeIcon('notebook-revert'),
tooltip: resetDescription || resetTitle,
};
let resetButtonClicked = false;
const step = createPickStep<BranchQuickPickItem>({
title: appendReposToTitle(title ?? context.title, state, context),
placeholder: count => (!count ? `No branches found in ${state.repo.formattedName}` : placeholder),
matchOnDetail: true,
items: items,
additionalButtons: [resetButton],
onDidClickButton: (_quickpick, button) => {
if (button === resetButton) {
resetButtonClicked = true;
return true;
}
return false;
},
onDidClickItemButton: (_quickpick, button, { item }) => {
if (button === RevealInSideBarQuickInputButton) {
void BranchActions.reveal(item, { select: true, focus: false, expand: true });
}
},
keys: ['right', 'alt+right', 'ctrl+right'],
onDidPressKey: async (_quickpick, _key, { item }) => {
await BranchActions.reveal(item, {
select: true,
focus: false,
expand: true,
});
},
});

const selection: StepSelection<typeof step> = yield step;
if (resetButtonClicked) {
return undefined;
}
return canPickStepContinue(step, state, selection) ? selection[0].item : StepResultBreak;
}

export function* pickBranchesStep<
State extends PartialStepState & { repo: Repository },
Context extends { repos: Repository[]; showTags?: boolean; title: string },
Expand Down Expand Up @@ -1538,7 +1616,16 @@ export async function* pickRepositoryStep<
state.repo = Container.instance.git.getRepository(state.repo);
if (state.repo != null) return state.repo;
}
const active = state.repo ?? (await Container.instance.git.getOrOpenRepositoryForEditor());
let active;
try {
active = state.repo ?? (await Container.instance.git.getOrOpenRepositoryForEditor());
} catch (ex) {
Logger.log(
'pickRepositoryStep: failed to get active repository. Normally it happens when the currently open file does not belong to a repository',
ex,
);
active = undefined;
}

const step = createPickStep<RepositoryQuickPickItem>({
title: context.title,
Expand Down
17 changes: 15 additions & 2 deletions src/commands/quickWizard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,26 @@ import type { Container } from '../container';
import type { LaunchpadCommandArgs } from '../plus/launchpad/launchpad';
import type { AssociateIssueWithBranchCommandArgs, StartWorkCommandArgs } from '../plus/startWork/startWork';
import { command } from '../system/-webview/command';
import type { ChangeBranchMergeTargetCommandArgs } from './changeBranchMergeTarget';
import type { CommandContext } from './commandContext';
import type { QuickWizardCommandArgsWithCompletion } from './quickWizard.base';
import { QuickWizardCommandBase } from './quickWizard.base';

export type QuickWizardCommandArgs = LaunchpadCommandArgs | StartWorkCommandArgs | AssociateIssueWithBranchCommandArgs;
export type QuickWizardCommandArgs =
| LaunchpadCommandArgs
| StartWorkCommandArgs
| AssociateIssueWithBranchCommandArgs
| ChangeBranchMergeTargetCommandArgs;

@command()
export class QuickWizardCommand extends QuickWizardCommandBase {
constructor(container: Container) {
super(container, ['gitlens.showLaunchpad', 'gitlens.startWork', 'gitlens.associateIssueWithBranch']);
super(container, [
'gitlens.showLaunchpad',
'gitlens.startWork',
'gitlens.associateIssueWithBranch',
'gitlens.changeBranchMergeTarget',
]);
}

protected override preExecute(context: CommandContext, args?: QuickWizardCommandArgsWithCompletion): Promise<void> {
Expand All @@ -25,6 +35,9 @@ export class QuickWizardCommand extends QuickWizardCommandBase {
case 'gitlens.associateIssueWithBranch':
return this.execute({ command: 'associateIssueWithBranch', ...args });

case 'gitlens.changeBranchMergeTarget':
return this.execute({ command: 'changeBranchMergeTarget', ...args });

default:
return this.execute(args);
}
Expand Down
5 changes: 5 additions & 0 deletions src/commands/quickWizard.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { LaunchpadCommand } from '../plus/launchpad/launchpad';
import { AssociateIssueWithBranchCommand, StartWorkCommand } from '../plus/startWork/startWork';
import { configuration } from '../system/-webview/configuration';
import { getContext } from '../system/-webview/context';
import { ChangeBranchMergeTargetCommand } from './changeBranchMergeTarget';
import { BranchGitCommand } from './git/branch';
import { CherryPickGitCommand } from './git/cherry-pick';
import { CoAuthorsGitCommand } from './git/coauthors';
Expand Down Expand Up @@ -122,6 +123,10 @@ export class QuickWizardRootStep implements QuickPickStep<QuickCommand> {
if (args?.command === 'associateIssueWithBranch') {
this.hiddenItems.push(new AssociateIssueWithBranchCommand(container, args));
}

if (args?.command === 'changeBranchMergeTarget') {
this.hiddenItems.push(new ChangeBranchMergeTargetCommand(container, args));
}
}

private _command: QuickCommand | undefined;
Expand Down
1 change: 1 addition & 0 deletions src/constants.commands.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -619,6 +619,7 @@ export type ContributedPaletteCommands =
| 'gitlens.browseRepoAtRevisionInNewWindow'
| 'gitlens.browseRepoBeforeRevision'
| 'gitlens.browseRepoBeforeRevisionInNewWindow'
| 'gitlens.changeBranchMergeTarget'
| 'gitlens.clearFileAnnotations'
| 'gitlens.closeUnchangedFiles'
| 'gitlens.compareHeadWith'
Expand Down
Loading