Skip to content

Commit cfe8bce

Browse files
committed
Adds branch merge target merge detection
Adds fetch and comparison actions to merge target Adds merged indicator status to active section on Home
1 parent cb7e263 commit cfe8bce

File tree

12 files changed

+396
-143
lines changed

12 files changed

+396
-143
lines changed

Diff for: src/constants.commands.ts

+1
Original file line numberDiff line numberDiff line change
@@ -685,6 +685,7 @@ export type TreeViewCommandSuffixesByViewType<T extends TreeViewTypes> = Extract
685685
>;
686686

687687
type HomeWebviewCommands = `home.${
688+
| 'openMergeTargetComparison'
688689
| 'openPullRequestChanges'
689690
| 'openPullRequestComparison'
690691
| 'openPullRequestOnRemote'

Diff for: src/env/node/git/sub-providers/branches.ts

+82-3
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@ import { GitErrorHandling } from '../../../../git/commandOptions';
55
import type {
66
BranchContributionsOverview,
77
GitBranchesSubProvider,
8+
GitBranchMergedStatus,
89
PagedResult,
910
PagingOptions,
1011
} from '../../../../git/gitProvider';
1112
import { GitBranch } from '../../../../git/models/branch';
1213
import { getLocalBranchByUpstream, isDetachedHead } from '../../../../git/models/branch.utils';
1314
import type { MergeConflict } from '../../../../git/models/mergeConflict';
15+
import type { GitBranchReference } from '../../../../git/models/reference';
1416
import { createRevisionRange } from '../../../../git/models/revision.utils';
1517
import { parseGitBranches } from '../../../../git/parsers/branchParser';
1618
import { parseMergeTreeConflict } from '../../../../git/parsers/mergeTreeParser';
@@ -310,6 +312,74 @@ export class BranchesGitSubProvider implements GitBranchesSubProvider {
310312
await this.git.branch(repoPath, name, ref);
311313
}
312314

315+
@log()
316+
async getBranchMergedStatus(
317+
repoPath: string,
318+
branch: GitBranchReference,
319+
into: GitBranchReference,
320+
): Promise<GitBranchMergedStatus> {
321+
const result = await this.getBranchMergedStatusCore(repoPath, branch, into);
322+
if (result.merged) return result;
323+
324+
// If the branch we are checking is a remote branch, check if it has been merged into its local branch (if there is one)
325+
if (into.remote) {
326+
const localIntoBranch = await this.getLocalBranchByUpstream(repoPath, into.name);
327+
// If there is a local branch and it is not the branch we are checking, check if it has been merged into it
328+
if (localIntoBranch != null && localIntoBranch.name !== branch.name) {
329+
const result = await this.getBranchMergedStatusCore(repoPath, branch, localIntoBranch);
330+
if (result.merged) {
331+
return {
332+
...result,
333+
localBranchOnly: { name: localIntoBranch.name },
334+
};
335+
}
336+
}
337+
}
338+
339+
return { merged: false };
340+
}
341+
342+
private async getBranchMergedStatusCore(
343+
repoPath: string,
344+
branch: GitBranchReference,
345+
into: GitBranchReference,
346+
): Promise<Exclude<GitBranchMergedStatus, 'localBranchOnly'>> {
347+
const scope = getLogScope();
348+
349+
try {
350+
// Check if branch is direct ancestor (handles FF merges)
351+
try {
352+
await this.git.exec(
353+
{ cwd: repoPath, errors: GitErrorHandling.Throw },
354+
'merge-base',
355+
'--is-ancestor',
356+
branch.name,
357+
into.name,
358+
);
359+
return { merged: true, confidence: 'highest' };
360+
} catch {}
361+
362+
// Cherry-pick detection (handles cherry-picks, rebases, etc)
363+
const data = await this.git.exec<string>(
364+
{ cwd: repoPath },
365+
'cherry',
366+
'--abbrev',
367+
'-v',
368+
into.name,
369+
branch.name,
370+
);
371+
// Check if there are no lines or all lines startwith a `-` (i.e. likely merged)
372+
if (!data || data.split('\n').every(l => l.startsWith('-'))) {
373+
return { merged: true, confidence: 'high' };
374+
}
375+
376+
return { merged: false };
377+
} catch (ex) {
378+
Logger.error(ex, scope);
379+
return { merged: false };
380+
}
381+
}
382+
313383
@log()
314384
async getLocalBranchByUpstream(repoPath: string, remoteBranchName: string): Promise<GitBranch | undefined> {
315385
const branches = new PageableResult<GitBranch>(p =>
@@ -425,7 +495,12 @@ export class BranchesGitSubProvider implements GitBranchesSubProvider {
425495
if (match != null && match.length === 2) {
426496
let name: string | undefined = match[1];
427497
if (name !== 'HEAD') {
428-
name = await this.getValidatedBranchName(repoPath, options?.upstream ? `${name}@{u}` : name);
498+
if (options?.upstream) {
499+
const upstream = await this.getValidatedBranchName(repoPath, `${name}@{u}`);
500+
if (upstream) return upstream;
501+
}
502+
503+
name = await this.getValidatedBranchName(repoPath, name);
429504
if (name) return name;
430505
}
431506
}
@@ -438,13 +513,17 @@ export class BranchesGitSubProvider implements GitBranchesSubProvider {
438513
`--grep-reflog=checkout: moving from .* to ${ref.replace('refs/heads/', '')}`,
439514
);
440515
entries = data.split('\n').filter(entry => Boolean(entry));
441-
442516
if (!entries.length) return undefined;
443517

444518
match = entries[entries.length - 1].match(/checkout: moving from ([^\s]+)\s/);
445519
if (match != null && match.length === 2) {
446520
let name: string | undefined = match[1];
447-
name = await this.getValidatedBranchName(repoPath, options?.upstream ? `${name}@{u}` : name);
521+
if (options?.upstream) {
522+
const upstream = await this.getValidatedBranchName(repoPath, `${name}@{u}`);
523+
if (upstream) return upstream;
524+
}
525+
526+
name = await this.getValidatedBranchName(repoPath, name);
448527
if (name) return name;
449528
}
450529
} catch {}

Diff for: src/git/gitProvider.ts

+18
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,12 @@ export interface GitRepositoryProvider {
370370
worktrees?: GitWorktreesSubProvider;
371371
}
372372

373+
export type MergeDetectionConfidence = 'highest' | 'high' | 'medium';
374+
375+
export type GitBranchMergedStatus =
376+
| { merged: false }
377+
| { merged: true; confidence: MergeDetectionConfidence; localBranchOnly?: { name: string } };
378+
373379
export interface GitBranchesSubProvider {
374380
getBranch(repoPath: string, name?: string): Promise<GitBranch | undefined>;
375381
getBranches(
@@ -398,6 +404,18 @@ export interface GitBranchesSubProvider {
398404
): Promise<string | undefined>;
399405

400406
createBranch?(repoPath: string, name: string, ref: string): Promise<void>;
407+
/**
408+
* Returns whether a branch has been merged into another branch
409+
* @param repoPath The repository path
410+
* @param branch The branch to check if merged
411+
* @param into The branch to check if merged into
412+
* @returns A promise of whether the branch is merged
413+
*/
414+
getBranchMergedStatus?(
415+
repoPath: string,
416+
branch: GitBranchReference,
417+
into: GitBranchReference,
418+
): Promise<GitBranchMergedStatus>;
401419
getLocalBranchByUpstream?(repoPath: string, remoteBranchName: string): Promise<GitBranch | undefined>;
402420
getPotentialMergeOrRebaseConflict?(
403421
repoPath: string,

Diff for: src/webviews/apps/plus/home/components/active-work.ts

-5
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@ import { createCommandLink } from '../../../../../system/commands';
99
import { createWebviewCommandLink } from '../../../../../system/webview';
1010
import type { GetOverviewBranch, OpenInGraphParams, State } from '../../../../home/protocol';
1111
import { stateContext } from '../../../home/context';
12-
import { ipcContext } from '../../../shared/context';
13-
import type { HostIpc } from '../../../shared/ipc';
1412
import { linkStyles } from '../../shared/components/vscode.css';
1513
import { branchCardStyles, GlBranchCardBase } from './branch-card';
1614
import type { Overview, OverviewState } from './overviewState';
@@ -64,9 +62,6 @@ export class GlActiveWork extends SignalWatcher(LitElement) {
6462
@consume({ context: overviewStateContext })
6563
private _overviewState!: OverviewState;
6664

67-
@consume({ context: ipcContext })
68-
private _ipc!: HostIpc;
69-
7065
override connectedCallback() {
7166
super.connectedCallback();
7267

Diff for: src/webviews/apps/plus/home/components/branch-card.ts

+32-6
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import type { AssociateIssueWithBranchCommandArgs } from '../../../../../plus/st
1515
import { createCommandLink } from '../../../../../system/commands';
1616
import { fromNow } from '../../../../../system/date';
1717
import { interpolate, pluralize } from '../../../../../system/string';
18-
import type { GetOverviewBranch, OpenInGraphParams } from '../../../../home/protocol';
18+
import type { BranchRef, GetOverviewBranch, OpenInGraphParams } from '../../../../home/protocol';
1919
import { renderBranchName } from '../../../shared/components/branch-name';
2020
import type { GlCard } from '../../../shared/components/card/card';
2121
import { GlElement, observe } from '../../../shared/components/element';
@@ -246,6 +246,7 @@ export abstract class GlBranchCardBase extends GlElement {
246246
this.contributorsPromise = value?.contributors;
247247
this.issuesPromise = value?.issues;
248248
this.prPromise = value?.pr;
249+
this.mergeTargetPromise = value?.mergeTarget;
249250
this.wipPromise = value?.wip;
250251
}
251252

@@ -355,6 +356,26 @@ export abstract class GlBranchCardBase extends GlElement {
355356
);
356357
}
357358

359+
@state()
360+
private _mergeTarget!: Awaited<GetOverviewBranch['mergeTarget']>;
361+
get mergeTarget() {
362+
return this._mergeTarget;
363+
}
364+
365+
private _mergeTargetPromise!: GetOverviewBranch['mergeTarget'];
366+
get mergeTargetPromise() {
367+
return this._mergeTargetPromise;
368+
}
369+
set mergeTargetPromise(value: GetOverviewBranch['mergeTarget']) {
370+
if (this._mergeTargetPromise === value) return;
371+
372+
this._mergeTargetPromise = value;
373+
void this._mergeTargetPromise?.then(
374+
r => (this._mergeTarget = r),
375+
() => (this._mergeTarget = undefined),
376+
);
377+
}
378+
358379
@state()
359380
private _wip!: Awaited<GetOverviewBranch['wip']>;
360381
get wip() {
@@ -391,10 +412,11 @@ export abstract class GlBranchCardBase extends GlElement {
391412
this.attachFocusListener();
392413
}
393414

394-
get branchRefs() {
415+
get branchRef(): BranchRef {
395416
return {
396417
repoPath: this.repo,
397418
branchId: this.branch.id,
419+
branchName: this.branch.name,
398420
};
399421
}
400422

@@ -406,7 +428,7 @@ export abstract class GlBranchCardBase extends GlElement {
406428
return getLaunchpadItemGrouping(getLaunchpadItemGroup(this.pr, this.launchpadItem)) ?? 'base';
407429
}
408430

409-
get branchCardIndicator() {
431+
get branchCardIndicator(): GlCard['indicator'] {
410432
if (!this.branch.opened) return undefined;
411433

412434
if (this.wip?.pausedOpStatus != null) {
@@ -431,6 +453,10 @@ export abstract class GlBranchCardBase extends GlElement {
431453
return 'branch-changes';
432454
}
433455

456+
if (this.mergeTarget?.mergedStatus?.merged) {
457+
return 'branch-merged';
458+
}
459+
434460
switch (this.branch.status) {
435461
case 'ahead':
436462
return 'branch-ahead';
@@ -598,7 +624,7 @@ export abstract class GlBranchCardBase extends GlElement {
598624
}
599625

600626
protected createCommandLink<T>(command: Commands, args?: T | any) {
601-
return createCommandLink<T>(command, args ?? this.branchRefs);
627+
return createCommandLink<T>(command, args ?? this.branchRef);
602628
}
603629

604630
protected renderTimestamp() {
@@ -759,7 +785,7 @@ export abstract class GlBranchCardBase extends GlElement {
759785

760786
return html`<gl-merge-target-status
761787
class="branch-item__merge-target"
762-
.branch=${this.branch.name}
788+
.branch=${this.branch}
763789
.targetPromise=${this.branch.mergeTarget}
764790
></gl-merge-target-status>`;
765791
}
@@ -855,7 +881,7 @@ export class GlBranchCard extends GlBranchCardBase {
855881
label="Open in Commit Graph"
856882
icon="gl-graph"
857883
href=${createCommandLink('gitlens.home.openInGraph', {
858-
...this.branchRefs,
884+
...this.branchRef,
859885
type: 'branch',
860886
} satisfies OpenInGraphParams)}
861887
></action-item>`,

0 commit comments

Comments
 (0)