Skip to content

Commit

Permalink
Git - implement branch protection provider (microsoft#179752)
Browse files Browse the repository at this point in the history
* Branch protection using settings is working

* Revert extension api changes

* Refactor code
  • Loading branch information
lszomoru committed Apr 12, 2023
1 parent e551221 commit a1eb9e2
Show file tree
Hide file tree
Showing 7 changed files with 126 additions and 27 deletions.
4 changes: 2 additions & 2 deletions extensions/git/src/actionButton.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export class ActionButtonCommand {
repository.onDidRunGitStatus(this.onDidRunGitStatus, this, this.disposables);
repository.onDidChangeOperations(this.onDidChangeOperations, this, this.disposables);

this.disposables.push(repository.onDidChangeBranchProtection(() => this._onDidChange.fire()));
this.disposables.push(postCommitCommandCenter.onDidChange(() => this._onDidChange.fire()));

const root = Uri.file(repository.root);
Expand All @@ -61,8 +62,7 @@ export class ActionButtonCommand {
this.onDidChangeSmartCommitSettings();
}

if (e.affectsConfiguration('git.branchProtection', root) ||
e.affectsConfiguration('git.branchProtectionPrompt', root) ||
if (e.affectsConfiguration('git.branchProtectionPrompt', root) ||
e.affectsConfiguration('git.postCommitCommand', root) ||
e.affectsConfiguration('git.rememberPostCommitCommand', root) ||
e.affectsConfiguration('git.showActionButton', root)) {
Expand Down
5 changes: 5 additions & 0 deletions extensions/git/src/api/git.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,11 @@ export interface PushErrorHandler {
handlePushError(repository: Repository, remote: Remote, refspec: string, error: Error & { gitErrorCode: GitErrorCodes }): Promise<boolean>;
}

export interface BranchProtectionProvider {
onDidChangeBranchProtection: Event<Uri>;
provideBranchProtection(): Map<string, string[]>;
}

export type APIState = 'uninitialized' | 'initialized';

export interface PublishEvent {
Expand Down
50 changes: 50 additions & 0 deletions extensions/git/src/branchProtection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { Disposable, Event, EventEmitter, Uri, workspace } from 'vscode';
import { BranchProtectionProvider } from './api/git';
import { dispose, filterEvent } from './util';

export interface IBranchProtectionProviderRegistry {
readonly onDidChangeBranchProtectionProviders: Event<Uri>;

getBranchProtectionProviders(root: Uri): BranchProtectionProvider[];
registerBranchProtectionProvider(root: Uri, provider: BranchProtectionProvider): Disposable;
}

export class GitBranchProtectionProvider implements BranchProtectionProvider {

private readonly _onDidChangeBranchProtection = new EventEmitter<Uri>();
onDidChangeBranchProtection = this._onDidChangeBranchProtection.event;

private branchProtection = new Map<'', string[]>();
private disposables: Disposable[] = [];

constructor(private readonly repositoryRoot: Uri) {
const onDidChangeBranchProtectionEvent = filterEvent(workspace.onDidChangeConfiguration, e => e.affectsConfiguration('git.branchProtection', repositoryRoot));
onDidChangeBranchProtectionEvent(this.updateBranchProtection, this, this.disposables);
this.updateBranchProtection();
}

provideBranchProtection(): Map<string, string[]> {
return this.branchProtection;
}

private updateBranchProtection(): void {
const scopedConfig = workspace.getConfiguration('git', this.repositoryRoot);
const branchProtectionConfig = scopedConfig.get<unknown>('branchProtection') ?? [];
const branchProtectionValues = Array.isArray(branchProtectionConfig) ? branchProtectionConfig : [branchProtectionConfig];

this.branchProtection.set('', branchProtectionValues
.map(bp => typeof bp === 'string' ? bp.trim() : '')
.filter(bp => bp !== ''));

this._onDidChangeBranchProtection.fire(this.repositoryRoot);
}

dispose(): void {
this.disposables = dispose(this.disposables);
}
}
2 changes: 1 addition & 1 deletion extensions/git/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { pickRemoteSource } from './remoteSource';
class CheckoutItem implements QuickPickItem {

protected get shortCommit(): string { return (this.ref.commit || '').substr(0, 8); }
get label(): string { return `${this.repository.isBranchProtected(this.ref.name ?? '') ? '$(lock)' : '$(git-branch)'} ${this.ref.name || this.shortCommit}`; }
get label(): string { return `${this.repository.isBranchProtected(this.ref) ? '$(lock)' : '$(git-branch)'} ${this.ref.name || this.shortCommit}`; }
get description(): string { return this.shortCommit; }
get refName(): string | undefined { return this.ref.name; }

Expand Down
36 changes: 33 additions & 3 deletions extensions/git/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,13 @@ import { Git } from './git';
import * as path from 'path';
import * as fs from 'fs';
import { fromGitUri } from './uri';
import { APIState as State, CredentialsProvider, PushErrorHandler, PublishEvent, RemoteSourcePublisher, PostCommitCommandsProvider } from './api/git';
import { APIState as State, CredentialsProvider, PushErrorHandler, PublishEvent, RemoteSourcePublisher, PostCommitCommandsProvider, BranchProtectionProvider } from './api/git';
import { Askpass } from './askpass';
import { IPushErrorHandlerRegistry } from './pushError';
import { ApiRepository } from './api/api1';
import { IRemoteSourcePublisherRegistry } from './remotePublisher';
import { IPostCommitCommandsProviderRegistry } from './postCommitCommands';
import { IBranchProtectionProviderRegistry } from './branchProtection';

class RepositoryPick implements QuickPickItem {
@memoize get label(): string {
Expand Down Expand Up @@ -91,7 +92,7 @@ interface OpenRepository extends Disposable {
repository: Repository;
}

export class Model implements IRemoteSourcePublisherRegistry, IPostCommitCommandsProviderRegistry, IPushErrorHandlerRegistry {
export class Model implements IBranchProtectionProviderRegistry, IRemoteSourcePublisherRegistry, IPostCommitCommandsProviderRegistry, IPushErrorHandlerRegistry {

private _onDidOpenRepository = new EventEmitter<Repository>();
readonly onDidOpenRepository: Event<Repository> = this._onDidOpenRepository.event;
Expand Down Expand Up @@ -151,6 +152,11 @@ export class Model implements IRemoteSourcePublisherRegistry, IPostCommitCommand
private _onDidChangePostCommitCommandsProviders = new EventEmitter<void>();
readonly onDidChangePostCommitCommandsProviders = this._onDidChangePostCommitCommandsProviders.event;

private branchProtectionProviders = new Map<Uri, Set<BranchProtectionProvider>>();

private _onDidChangeBranchProtectionProviders = new EventEmitter<Uri>();
readonly onDidChangeBranchProtectionProviders = this._onDidChangeBranchProtectionProviders.event;

private pushErrorHandlers = new Set<PushErrorHandler>();

private _unsafeRepositories = new UnsafeRepositoryMap();
Expand Down Expand Up @@ -476,7 +482,7 @@ export class Model implements IRemoteSourcePublisherRegistry, IPostCommitCommand

// Open repository
const dotGit = await this.git.getRepositoryDotGit(repositoryRoot);
const repository = new Repository(this.git.open(repositoryRoot, dotGit, this.logger), this, this, this, this.globalState, this.logger, this.telemetryReporter);
const repository = new Repository(this.git.open(repositoryRoot, dotGit, this.logger), this, this, this, this, this.globalState, this.logger, this.telemetryReporter);

this.open(repository);
repository.status(); // do not await this, we want SCM to know about the repo asap
Expand Down Expand Up @@ -760,6 +766,30 @@ export class Model implements IRemoteSourcePublisherRegistry, IPostCommitCommand
return [...this.remoteSourcePublishers.values()];
}

registerBranchProtectionProvider(root: Uri, provider: BranchProtectionProvider): Disposable {
const providerDisposables: Disposable[] = [];

this.branchProtectionProviders.set(root, (this.branchProtectionProviders.get(root) ?? new Set()).add(provider));
providerDisposables.push(provider.onDidChangeBranchProtection(uri => this._onDidChangeBranchProtectionProviders.fire(uri)));

this._onDidChangeBranchProtectionProviders.fire(root);

return toDisposable(() => {
const providers = this.branchProtectionProviders.get(root);

if (providers && providers.has(provider)) {
providers.delete(provider);
this.branchProtectionProviders.set(root, providers);
}

dispose(providerDisposables);
});
}

getBranchProtectionProviders(root: Uri): BranchProtectionProvider[] {
return [...(this.branchProtectionProviders.get(root) ?? new Set()).values()];
}

registerPostCommitCommandsProvider(provider: PostCommitCommandsProvider): Disposable {
this.postCommitCommandsProviders.add(provider);
this._onDidChangePostCommitCommandsProviders.fire();
Expand Down
55 changes: 34 additions & 21 deletions extensions/git/src/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { IRemoteSourcePublisherRegistry } from './remotePublisher';
import { ActionButtonCommand } from './actionButton';
import { IPostCommitCommandsProviderRegistry, CommitCommandsCenter } from './postCommitCommands';
import { Operation, OperationKind, OperationManager, OperationResult } from './operation';
import { GitBranchProtectionProvider, IBranchProtectionProviderRegistry } from './branchProtection';

const timeout = (millis: number) => new Promise(c => setTimeout(c, millis));

Expand Down Expand Up @@ -624,6 +625,9 @@ export class Repository implements Disposable {
private _onDidRunOperation = new EventEmitter<OperationResult>();
readonly onDidRunOperation: Event<OperationResult> = this._onDidRunOperation.event;

private _onDidChangeBranchProtection = new EventEmitter<void>();
readonly onDidChangeBranchProtection: Event<void> = this._onDidChangeBranchProtection.event;

@memoize
get onDidChangeOperations(): Event<void> {
return anyEvent(this.onRunOperation as Event<any>, this.onDidRunOperation as Event<any>);
Expand Down Expand Up @@ -740,7 +744,7 @@ export class Repository implements Disposable {
private isRepositoryHuge: false | { limit: number } = false;
private didWarnAboutLimit = false;

private isBranchProtectedMatcher: picomatch.Matcher | undefined;
private branchProtection = new Map<string, picomatch.Matcher | undefined>();
private commitCommandCenter: CommitCommandsCenter;
private resourceCommandResolver = new ResourceCommandResolver(this);
private updateModelStateCancellationTokenSource: CancellationTokenSource | undefined;
Expand All @@ -751,6 +755,7 @@ export class Repository implements Disposable {
private pushErrorHandlerRegistry: IPushErrorHandlerRegistry,
remoteSourcePublisherRegistry: IRemoteSourcePublisherRegistry,
postCommitCommandsProviderRegistry: IPostCommitCommandsProviderRegistry,
private readonly branchProtectionProviderRegistry: IBranchProtectionProviderRegistry,
globalState: Memento,
private readonly logger: LogOutputChannel,
private telemetryReporter: TelemetryReporter
Expand Down Expand Up @@ -816,8 +821,7 @@ export class Repository implements Disposable {
}, undefined, this.disposables);

filterEvent(workspace.onDidChangeConfiguration, e =>
e.affectsConfiguration('git.branchProtection', root)
|| e.affectsConfiguration('git.branchSortOrder', root)
e.affectsConfiguration('git.branchSortOrder', root)
|| e.affectsConfiguration('git.untrackedChanges', root)
|| e.affectsConfiguration('git.ignoreSubmodules', root)
|| e.affectsConfiguration('git.openDiffOnClick', root)
Expand Down Expand Up @@ -862,9 +866,10 @@ export class Repository implements Disposable {
}
}, null, this.disposables);

const onDidChangeBranchProtection = filterEvent(workspace.onDidChangeConfiguration, e => e.affectsConfiguration('git.branchProtection', root));
onDidChangeBranchProtection(this.updateBranchProtectionMatcher, this, this.disposables);
this.updateBranchProtectionMatcher();
// Default branch protection provider
const onBranchProtectionProviderChanged = filterEvent(this.branchProtectionProviderRegistry.onDidChangeBranchProtectionProviders, e => pathEquals(e.fsPath, root.fsPath));
this.disposables.push(onBranchProtectionProviderChanged(root => this.updateBranchProtectionMatchers(root)));
this.disposables.push(this.branchProtectionProviderRegistry.registerBranchProtectionProvider(root, new GitBranchProtectionProvider(root)));

const statusBar = new StatusBarCommands(this, remoteSourcePublisherRegistry);
this.disposables.push(statusBar);
Expand Down Expand Up @@ -2358,20 +2363,14 @@ export class Repository implements Disposable {
}
}

private updateBranchProtectionMatcher(): void {
const scopedConfig = workspace.getConfiguration('git', Uri.file(this.repository.root));
const branchProtectionConfig = scopedConfig.get<unknown>('branchProtection') ?? [];
const branchProtectionValues = Array.isArray(branchProtectionConfig) ? branchProtectionConfig : [branchProtectionConfig];

const branchProtectionGlobs = branchProtectionValues
.map(bp => typeof bp === 'string' ? bp.trim() : '')
.filter(bp => bp !== '');

if (branchProtectionGlobs.length === 0) {
this.isBranchProtectedMatcher = undefined;
} else {
this.isBranchProtectedMatcher = picomatch(branchProtectionGlobs);
private updateBranchProtectionMatchers(root: Uri): void {
for (const provider of this.branchProtectionProviderRegistry.getBranchProtectionProviders(root)) {
for (const [remote, branches] of provider.provideBranchProtection().entries()) {
this.branchProtection.set(remote, branches.length !== 0 ? picomatch(branches) : undefined);
}
}

this._onDidChangeBranchProtection.fire();
}

private optimisticUpdateEnabled(): boolean {
Expand Down Expand Up @@ -2411,8 +2410,22 @@ export class Repository implements Disposable {
return true;
}

public isBranchProtected(name = this.HEAD?.name ?? ''): boolean {
return this.isBranchProtectedMatcher ? this.isBranchProtectedMatcher(name) : false;
public isBranchProtected(branch = this.HEAD): boolean {
if (branch?.name) {
// Default branch protection (settings)
const defaultBranchProtectionMatcher = this.branchProtection.get('');
if (defaultBranchProtectionMatcher && defaultBranchProtectionMatcher(branch.name)) {
return true;
}

if (branch.upstream?.remote) {
// Branch protection (contributed)
const remoteBranchProtectionMatcher = this.branchProtection.get(branch.upstream.remote);
return remoteBranchProtectionMatcher ? remoteBranchProtectionMatcher(branch.name) : false;
}
}

return false;
}

dispose(): void {
Expand Down
1 change: 1 addition & 0 deletions extensions/git/src/statusbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class CheckoutStatusBar {

repository.onDidChangeOperations(this.onDidChangeOperations, this, this.disposables);
repository.onDidRunGitStatus(this._onDidChange.fire, this._onDidChange, this.disposables);
repository.onDidChangeBranchProtection(this._onDidChange.fire, this._onDidChange, this.disposables);
}

get command(): Command | undefined {
Expand Down

1 comment on commit a1eb9e2

@vercel
Copy link

@vercel vercel bot commented on a1eb9e2 Apr 12, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.