import { workspace, WorkspaceFoldersChangeEvent, Uri, window, Event, EventEmitter, QuickPickItem, Disposable, SourceControl, SourceControlResourceGroup, TextEditor, Memento, commands, LogOutputChannel, l10n, ProgressLocation, WorkspaceFolder, ThemeIcon } from 'vscode';
import TelemetryReporter from '@vscode/extension-telemetry';
import { IRepositoryResolver, Repository, RepositoryState } from './repository';
import { memoize, sequentialize, debounce } from './decorators';
import { dispose, anyEvent, filterEvent, isDescendant, pathEquals, toDisposable, eventToPromise } from './util';
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, BranchProtectionProvider, SourceControlHistoryItemDetailsProvider } 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';
import { ISourceControlHistoryItemDetailsProviderRegistry } from './historyItemDetailsProvider';
class RepositoryPick implements QuickPickItem {
@memoize get label(): string {
return path.basename(this.repository.root);
}
@memoize get description(): string {
return [this.repository.headLabel, this.repository.syncLabel]
.filter(l => !!l)
.join(' ');
}
@memoize get iconPath(): ThemeIcon {
switch (this.repository.kind) {
case 'submodule':
return new ThemeIcon('archive');
case 'worktree':
return new ThemeIcon('list-tree');
default:
return new ThemeIcon('repo');
}
}
constructor(public readonly repository: Repository, public readonly index: number) { }
}
export interface ModelChangeEvent {
repository: Repository;
uri: Uri;
}
export interface OriginalResourceChangeEvent {
repository: Repository;
uri: Uri;
}
interface OpenRepository extends Disposable {
repository: Repository;
}
class ClosedRepositoriesManager {
private _repositories: Set<string>;
get repositories(): string[] {
return [...this._repositories.values()];
}
constructor(private readonly workspaceState: Memento) {
this._repositories = new Set<string>(workspaceState.get<string[]>('closedRepositories', []));
this.onDidChangeRepositories();
}
addRepository(repository: string): void {
this._repositories.add(repository);
this.onDidChangeRepositories();
}
deleteRepository(repository: string): boolean {
const result = this._repositories.delete(repository);
if (result) {
this.onDidChangeRepositories();
}
return result;
}
isRepositoryClosed(repository: string): boolean {
return this._repositories.has(repository);
}
private onDidChangeRepositories(): void {
this.workspaceState.update('closedRepositories', [...this._repositories.values()]);
commands.executeCommand('setContext', 'git.closedRepositoryCount', this._repositories.size);
}
}
class ParentRepositoriesManager {
private _repositories = new Set<string>;
get repositories(): string[] {
return [...this._repositories.values()];
}
constructor(private readonly globalState: Memento) {
this.onDidChangeRepositories();
}
addRepository(repository: string): void {
this._repositories.add(repository);
this.onDidChangeRepositories();
}
deleteRepository(repository: string): boolean {
const result = this._repositories.delete(repository);
if (result) {
this.onDidChangeRepositories();
}
return result;
}
hasRepository(repository: string): boolean {
return this._repositories.has(repository);
}
openRepository(repository: string): void {
this.globalState.update(`parentRepository:${repository}`, true);
this.deleteRepository(repository);
}
private onDidChangeRepositories(): void {
commands.executeCommand('setContext', 'git.parentRepositoryCount', this._repositories.size);
}
}
class UnsafeRepositoriesManager {
private _repositories = new Map<string, string>();
get repositories(): string[] {
return [...this._repositories.keys()];
}
constructor() {
this.onDidChangeRepositories();
}
addRepository(repository: string, path: string): void {
this._repositories.set(repository, path);
this.onDidChangeRepositories();
}
deleteRepository(repository: string): boolean {
const result = this._repositories.delete(repository);
if (result) {
this.onDidChangeRepositories();
}
return result;
}
getRepositoryPath(repository: string): string | undefined {
return this._repositories.get(repository);
}
hasRepository(repository: string): boolean {
return this._repositories.has(repository);
}
private onDidChangeRepositories(): void {
commands.executeCommand('setContext', 'git.unsafeRepositoryCount', this._repositories.size);
}
}
export class Model implements IRepositoryResolver, IBranchProtectionProviderRegistry, IRemoteSourcePublisherRegistry, IPostCommitCommandsProviderRegistry, IPushErrorHandlerRegistry, ISourceControlHistoryItemDetailsProviderRegistry {
private _onDidOpenRepository = new EventEmitter<Repository>();
readonly onDidOpenRepository: Event<Repository> = this._onDidOpenRepository.event;
private _onDidCloseRepository = new EventEmitter<Repository>();
readonly onDidCloseRepository: Event<Repository> = this._onDidCloseRepository.event;
private _onDidChangeRepository = new EventEmitter<ModelChangeEvent>();
readonly onDidChangeRepository: Event<ModelChangeEvent> = this._onDidChangeRepository.event;
private _onDidChangeOriginalResource = new EventEmitter<OriginalResourceChangeEvent>();
readonly onDidChangeOriginalResource: Event<OriginalResourceChangeEvent> = this._onDidChangeOriginalResource.event;
private openRepositories: OpenRepository[] = [];
get repositories(): Repository[] { return this.openRepositories.map(r => r.repository); }
private possibleGitRepositoryPaths = new Set<string>();
private _onDidChangeState = new EventEmitter<State>();
readonly onDidChangeState = this._onDidChangeState.event;
private _onDidPublish = new EventEmitter<PublishEvent>();
readonly onDidPublish = this._onDidPublish.event;
firePublishEvent(repository: Repository, branch?: string) {
this._onDidPublish.fire({ repository: new ApiRepository(repository), branch: branch });
}
private _state: State = 'uninitialized';
get state(): State { return this._state; }
setState(state: State): void {
this._state = state;
this._onDidChangeState.fire(state);
commands.executeCommand('setContext', 'git.state', state);
}
@memoize
get isInitialized(): Promise<void> {
if (this._state === 'initialized') {
return Promise.resolve();
}
return eventToPromise(filterEvent(this.onDidChangeState, s => s === 'initialized')) as Promise<any>;
}
private remoteSourcePublishers = new Set<RemoteSourcePublisher>();
private _onDidAddRemoteSourcePublisher = new EventEmitter<RemoteSourcePublisher>();
readonly onDidAddRemoteSourcePublisher = this._onDidAddRemoteSourcePublisher.event;
private _onDidRemoveRemoteSourcePublisher = new EventEmitter<RemoteSourcePublisher>();
readonly onDidRemoveRemoteSourcePublisher = this._onDidRemoveRemoteSourcePublisher.event;
private postCommitCommandsProviders = new Set<PostCommitCommandsProvider>();
private _onDidChangePostCommitCommandsProviders = new EventEmitter<void>();
readonly onDidChangePostCommitCommandsProviders = this._onDidChangePostCommitCommandsProviders.event;
private branchProtectionProviders = new Map<string, Set<BranchProtectionProvider>>();
private _onDidChangeBranchProtectionProviders = new EventEmitter<Uri>();
readonly onDidChangeBranchProtectionProviders = this._onDidChangeBranchProtectionProviders.event;
private pushErrorHandlers = new Set<PushErrorHandler>();
private historyItemDetailsProviders = new Set<SourceControlHistoryItemDetailsProvider>();
private _unsafeRepositoriesManager: UnsafeRepositoriesManager;
get unsafeRepositories(): string[] {
return this._unsafeRepositoriesManager.repositories;
}
private _parentRepositoriesManager: ParentRepositoriesManager;
get parentRepositories(): string[] {
return this._parentRepositoriesManager.repositories;
}
private _closedRepositoriesManager: ClosedRepositoriesManager;
get closedRepositories(): string[] {
return [...this._closedRepositoriesManager.repositories];
}
private _workspaceFolders = new Map<string, string>();
private disposables: Disposable[] = [];
constructor(readonly git: Git, private readonly askpass: Askpass, private globalState: Memento, readonly workspaceState: Memento, private logger: LogOutputChannel, private telemetryReporter: TelemetryReporter) {
this._closedRepositoriesManager = new ClosedRepositoriesManager(workspaceState);
this._parentRepositoriesManager = new ParentRepositoriesManager(globalState);
this._unsafeRepositoriesManager = new UnsafeRepositoriesManager();
workspace.onDidChangeWorkspaceFolders(this.onDidChangeWorkspaceFolders, this, this.disposables);
window.onDidChangeVisibleTextEditors(this.onDidChangeVisibleTextEditors, this, this.disposables);
window.onDidChangeActiveTextEditor(this.onDidChangeActiveTextEditor, this, this.disposables);
workspace.onDidChangeConfiguration(this.onDidChangeConfiguration, this, this.disposables);
const fsWatcher = workspace.createFileSystemWatcher('**');
this.disposables.push(fsWatcher);
const onWorkspaceChange = anyEvent(fsWatcher.onDidChange, fsWatcher.onDidCreate, fsWatcher.onDidDelete);
const onGitRepositoryChange = filterEvent(onWorkspaceChange, uri => /\/\.git/.test(uri.path));
const onPossibleGitRepositoryChange = filterEvent(onGitRepositoryChange, uri => !this.getRepository(uri));
onPossibleGitRepositoryChange(this.onPossibleGitRepositoryChange, this, this.disposables);
this.setState('uninitialized');
this.doInitialScan().finally(() => this.setState('initialized'));
}
private async doInitialScan(): Promise<void> {
this.logger.info('[Model][doInitialScan] Initial repository scan started');
const config = workspace.getConfiguration('git');
const autoRepositoryDetection = config.get<boolean | 'subFolders' | 'openEditors'>('autoRepositoryDetection');
const parentRepositoryConfig = config.get<'always' | 'never' | 'prompt'>('openRepositoryInParentFolders', 'prompt');
this.logger.trace(`[Model][doInitialScan] Settings: autoRepositoryDetection=${autoRepositoryDetection}, openRepositoryInParentFolders=${parentRepositoryConfig}`);
const initialScanFn = () => Promise.all([
this.onDidChangeWorkspaceFolders({ added: workspace.workspaceFolders || [], removed: [] }),
this.onDidChangeVisibleTextEditors(window.visibleTextEditors),
this.scanWorkspaceFolders()
]);
if (config.get<boolean>('showProgress', true)) {
await window.withProgress({ location: ProgressLocation.SourceControl }, initialScanFn);
} else {
await initialScanFn();
}
if (this.parentRepositories.length !== 0 &&
parentRepositoryConfig === 'prompt') {
this.showParentRepositoryNotification();
} else if (this.unsafeRepositories.length !== 0) {
this.showUnsafeRepositoryNotification();
}
this.telemetryReporter.sendTelemetryEvent('git.repositoryInitialScan', { autoRepositoryDetection: String(autoRepositoryDetection) }, { repositoryCount: this.openRepositories.length });
this.logger.info(`[Model][doInitialScan] Initial repository scan completed - repositories (${this.repositories.length}), closed repositories (${this.closedRepositories.length}), parent repositories (${this.parentRepositories.length}), unsafe repositories (${this.unsafeRepositories.length})`);
}
private async scanWorkspaceFolders(): Promise<void> {
try {
const config = workspace.getConfiguration('git');
const autoRepositoryDetection = config.get<boolean | 'subFolders' | 'openEditors'>('autoRepositoryDetection');
if (autoRepositoryDetection !== true && autoRepositoryDetection !== 'subFolders') {
return;
}
await Promise.all((workspace.workspaceFolders || []).map(async folder => {
const root = folder.uri.fsPath;
this.logger.trace(`[Model][scanWorkspaceFolders] Workspace folder: ${root}`);
const repositoryScanMaxDepth = (workspace.isTrusted ? workspace.getConfiguration('git', folder.uri) : config).get<number>('repositoryScanMaxDepth', 1);
const repositoryScanIgnoredFolders = (workspace.isTrusted ? workspace.getConfiguration('git', folder.uri) : config).get<string[]>('repositoryScanIgnoredFolders', []);
const subfolders = new Set(await this.traverseWorkspaceFolder(root, repositoryScanMaxDepth, repositoryScanIgnoredFolders));
const scanPaths = (workspace.isTrusted ? workspace.getConfiguration('git', folder.uri) : config).get<string[]>('scanRepositories') || [];
this.logger.trace(`[Model][scanWorkspaceFolders] Workspace scan settings: repositoryScanMaxDepth=${repositoryScanMaxDepth}; repositoryScanIgnoredFolders=[${repositoryScanIgnoredFolders.join(', ')}]; scanRepositories=[${scanPaths.join(', ')}]`);
for (const scanPath of scanPaths) {
if (scanPath === '.git') {
this.logger.trace('[Model][scanWorkspaceFolders] \'.git\' not supported in \'git.scanRepositories\' setting.');
continue;
}
if (path.isAbsolute(scanPath)) {
const notSupportedMessage = l10n.t('Absolute paths not supported in "git.scanRepositories" setting.');
this.logger.warn(`[Model][scanWorkspaceFolders] ${notSupportedMessage}`);
console.warn(notSupportedMessage);
continue;
}
subfolders.add(path.join(root, scanPath));
}
this.logger.trace(`[Model][scanWorkspaceFolders] Workspace scan sub folders: [${[...subfolders].join(', ')}]`);
await Promise.all([...subfolders].map(f => this.openRepository(f)));
}));
}
catch (err) {
this.logger.warn(`[Model][scanWorkspaceFolders] Error: ${err}`);
}
}
private async traverseWorkspaceFolder(workspaceFolder: string, maxDepth: number, repositoryScanIgnoredFolders: string[]): Promise<string[]> {
const result: string[] = [];
const foldersToTravers = [{ path: workspaceFolder, depth: 0 }];
while (foldersToTravers.length > 0) {
const currentFolder = foldersToTravers.shift()!;
const children: fs.Dirent[] = [];
try {
children.push(...await fs.promises.readdir(currentFolder.path, { withFileTypes: true }));
if (currentFolder.depth !== 0) {
result.push(currentFolder.path);
}
}
catch (err) {
this.logger.warn(`[Model][traverseWorkspaceFolder] Unable to read workspace folder '${currentFolder.path}': ${err}`);
continue;
}
if (currentFolder.depth < maxDepth || maxDepth === -1) {
const childrenFolders = children
.filter(dirent =>
dirent.isDirectory() && dirent.name !== '.git' &&
!repositoryScanIgnoredFolders.find(f => pathEquals(dirent.name, f)))
.map(dirent => path.join(currentFolder.path, dirent.name));
foldersToTravers.push(...childrenFolders.map(folder => {
return { path: folder, depth: currentFolder.depth + 1 };
}));
}
}
return result;
}
private onPossibleGitRepositoryChange(uri: Uri): void {
const config = workspace.getConfiguration('git');
const autoRepositoryDetection = config.get<boolean | 'subFolders' | 'openEditors'>('autoRepositoryDetection');
if (autoRepositoryDetection === false) {
return;
}
this.eventuallyScanPossibleGitRepository(uri.fsPath.replace(/\.git.*$/, ''));
}
private eventuallyScanPossibleGitRepository(path: string) {
this.possibleGitRepositoryPaths.add(path);
this.eventuallyScanPossibleGitRepositories();
}
@debounce(500)
private eventuallyScanPossibleGitRepositories(): void {
for (const path of this.possibleGitRepositoryPaths) {
this.openRepository(path, false, true);
}
this.possibleGitRepositoryPaths.clear();
}
private async onDidChangeWorkspaceFolders({ added, removed }: WorkspaceFoldersChangeEvent): Promise<void> {
try {
const possibleRepositoryFolders = added
.filter(folder => !this.getOpenRepository(folder.uri));
const activeRepositoriesList = window.visibleTextEditors
.map(editor => this.getRepository(editor.document.uri))
.filter(repository => !!repository) as Repository[];
const activeRepositories = new Set<Repository>(activeRepositoriesList);
const openRepositoriesToDispose = removed
.map(folder => this.getOpenRepository(folder.uri))
.filter(r => !!r)
.filter(r => !activeRepositories.has(r!.repository))
.filter(r => !(workspace.workspaceFolders || []).some(f => isDescendant(f.uri.fsPath, r!.repository.root))) as OpenRepository[];
openRepositoriesToDispose.forEach(r => r.dispose());
this.logger.trace(`[Model][onDidChangeWorkspaceFolders] Workspace folders: [${possibleRepositoryFolders.map(p => p.uri.fsPath).join(', ')}]`);
await Promise.all(possibleRepositoryFolders.map(p => this.openRepository(p.uri.fsPath)));
}
catch (err) {
this.logger.warn(`[Model][onDidChangeWorkspaceFolders] Error: ${err}`);
}
}
private onDidChangeConfiguration(): void {
const possibleRepositoryFolders = (workspace.workspaceFolders || [])
.filter(folder => workspace.getConfiguration('git', folder.uri).get<boolean>('enabled') === true)
.filter(folder => !this.getOpenRepository(folder.uri));
const openRepositoriesToDispose = this.openRepositories
.map(repository => ({ repository, root: Uri.file(repository.repository.root) }))
.filter(({ root }) => workspace.getConfiguration('git', root).get<boolean>('enabled') !== true)
.map(({ repository }) => repository);
this.logger.trace(`[Model][onDidChangeConfiguration] Workspace folders: [${possibleRepositoryFolders.map(p => p.uri.fsPath).join(', ')}]`);
possibleRepositoryFolders.forEach(p => this.openRepository(p.uri.fsPath));
openRepositoriesToDispose.forEach(r => r.dispose());
}
private async onDidChangeVisibleTextEditors(editors: readonly TextEditor[]): Promise<void> {
try {
if (!workspace.isTrusted) {
this.logger.trace('[Model][onDidChangeVisibleTextEditors] Workspace is not trusted.');
return;
}
const config = workspace.getConfiguration('git');
const autoRepositoryDetection = config.get<boolean | 'subFolders' | 'openEditors'>('autoRepositoryDetection');
if (autoRepositoryDetection !== true && autoRepositoryDetection !== 'openEditors') {
return;
}
await Promise.all(editors.map(async editor => {
const uri = editor.document.uri;
if (uri.scheme !== 'file') {
return;
}
const repository = this.getRepository(uri);
if (repository) {
this.logger.trace(`[Model][onDidChangeVisibleTextEditors] Repository for editor resource ${uri.fsPath} already exists: ${repository.root}`);
return;
}
this.logger.trace(`[Model][onDidChangeVisibleTextEditors] Open repository for editor resource ${uri.fsPath}`);
await this.openRepository(path.dirname(uri.fsPath));
}));
}
catch (err) {
this.logger.warn(`[Model][onDidChangeVisibleTextEditors] Error: ${err}`);
}
}
private onDidChangeActiveTextEditor(): void {
const textEditor = window.activeTextEditor;
if (textEditor === undefined) {
commands.executeCommand('setContext', 'git.activeResourceHasUnstagedChanges', false);
commands.executeCommand('setContext', 'git.activeResourceHasStagedChanges', false);
return;
}
const repository = this.getRepository(textEditor.document.uri);
if (!repository) {
commands.executeCommand('setContext', 'git.activeResourceHasUnstagedChanges', false);
commands.executeCommand('setContext', 'git.activeResourceHasStagedChanges', false);
return;
}
const indexResource = repository.indexGroup.resourceStates
.find(resource => pathEquals(resource.resourceUri.fsPath, textEditor.document.uri.fsPath));
const workingTreeResource = repository.workingTreeGroup.resourceStates
.find(resource => pathEquals(resource.resourceUri.fsPath, textEditor.document.uri.fsPath));
commands.executeCommand('setContext', 'git.activeResourceHasStagedChanges', indexResource !== undefined);
commands.executeCommand('setContext', 'git.activeResourceHasUnstagedChanges', workingTreeResource !== undefined);
}
@sequentialize
async openRepository(repoPath: string, openIfClosed = false, openIfParent = false): Promise<void> {
this.logger.trace(`[Model][openRepository] Repository: ${repoPath}`);
const existingRepository = await this.getRepositoryExact(repoPath);
if (existingRepository) {
this.logger.trace(`[Model][openRepository] Repository for path ${repoPath} already exists: ${existingRepository.root}`);
return;
}
const config = workspace.getConfiguration('git', Uri.file(repoPath));
const enabled = config.get<boolean>('enabled') === true;
if (!enabled) {
this.logger.trace('[Model][openRepository] Git is not enabled');
return;
}
if (!workspace.isTrusted) {
try {
fs.accessSync(path.join(repoPath, 'HEAD'), fs.constants.F_OK);
const result = await this.git.exec(repoPath, ['-C', repoPath, 'rev-parse', '--show-cdup']);
if (result.stderr.trim() === '' && result.stdout.trim() === '') {
this.logger.trace(`[Model][openRepository] Bare repository: ${repoPath}`);
return;
}
} catch {
}
}
try {
const { repositoryRoot, unsafeRepositoryMatch } = await this.getRepositoryRoot(repoPath);
this.logger.trace(`[Model][openRepository] Repository root for path ${repoPath} is: ${repositoryRoot}`);
const existingRepository = await this.getRepositoryExact(repositoryRoot);
if (existingRepository) {
this.logger.trace(`[Model][openRepository] Repository for path ${repositoryRoot} already exists: ${existingRepository.root}`);
return;
}
if (this.shouldRepositoryBeIgnored(repositoryRoot)) {
this.logger.trace(`[Model][openRepository] Repository for path ${repositoryRoot} is ignored`);
return;
}
const parentRepositoryConfig = config.get<'always' | 'never' | 'prompt'>('openRepositoryInParentFolders', 'prompt');
if (parentRepositoryConfig !== 'always' && this.globalState.get<boolean>(`parentRepository:${repositoryRoot}`) !== true) {
const isRepositoryOutsideWorkspace = await this.isRepositoryOutsideWorkspace(repositoryRoot);
if (!openIfParent && isRepositoryOutsideWorkspace) {
this.logger.trace(`[Model][openRepository] Repository in parent folder: ${repositoryRoot}`);
if (!this._parentRepositoriesManager.hasRepository(repositoryRoot)) {
if (this.state === 'initialized' && parentRepositoryConfig === 'prompt') {
this.showParentRepositoryNotification();
}
this._parentRepositoriesManager.addRepository(repositoryRoot);
}
return;
}
}
if (unsafeRepositoryMatch && unsafeRepositoryMatch.length === 3) {
this.logger.trace(`[Model][openRepository] Unsafe repository: ${repositoryRoot}`);
if (this._state === 'initialized' && !this._unsafeRepositoriesManager.hasRepository(repositoryRoot)) {
this.showUnsafeRepositoryNotification();
}
this._unsafeRepositoriesManager.addRepository(repositoryRoot, unsafeRepositoryMatch[2]);
return;
}
if (!openIfClosed && this._closedRepositoriesManager.isRepositoryClosed(repositoryRoot)) {
this.logger.trace(`[Model][openRepository] Repository for path ${repositoryRoot} is closed`);
return;
}
const [dotGit, repositoryRootRealPath] = await Promise.all([this.git.getRepositoryDotGit(repositoryRoot), this.getRepositoryRootRealPath(repositoryRoot)]);
const gitRepository = this.git.open(repositoryRoot, repositoryRootRealPath, dotGit, this.logger);
const repository = new Repository(gitRepository, this, this, this, this, this, this, this.globalState, this.logger, this.telemetryReporter);
this.open(repository);
this._closedRepositoriesManager.deleteRepository(repository.root);
this.logger.info(`[Model][openRepository] Opened repository (path): ${repository.root}`);
this.logger.info(`[Model][openRepository] Opened repository (real path): ${repository.rootRealPath ?? repository.root}`);
this.logger.info(`[Model][openRepository] Opened repository (kind): ${gitRepository.kind}`);
repository.status();
} catch (err) {
this.logger.trace(`[Model][openRepository] Opening repository for path='${repoPath}' failed. Error:${err}`);
}
}
async openParentRepository(repoPath: string): Promise<void> {
this._parentRepositoriesManager.openRepository(repoPath);
await this.openRepository(repoPath);
}
private async getRepositoryRoot(repoPath: string): Promise<{ repositoryRoot: string; unsafeRepositoryMatch: RegExpMatchArray | null }> {
try {
const rawRoot = await this.git.getRepositoryRoot(repoPath);
return { repositoryRoot: Uri.file(rawRoot).fsPath, unsafeRepositoryMatch: null };
} catch (err) {
const unsafeRepositoryMatch = /^fatal: detected dubious ownership in repository at \'([^']+)\'[\s\S]*git config --global --add safe\.directory '?([^'\n]+)'?$/m.exec(err.stderr);
if (unsafeRepositoryMatch && unsafeRepositoryMatch.length === 3) {
return { repositoryRoot: path.normalize(unsafeRepositoryMatch[1]), unsafeRepositoryMatch };
}
throw err;
}
}
private async getRepositoryRootRealPath(repositoryRoot: string): Promise<string | undefined> {
try {
const repositoryRootRealPath = await fs.promises.realpath(repositoryRoot);
return !pathEquals(repositoryRoot, repositoryRootRealPath) ? repositoryRootRealPath : undefined;
} catch (err) {
this.logger.warn(`[Model][getRepositoryRootRealPath] Failed to get repository realpath for "${repositoryRoot}": ${err}`);
return undefined;
}
}
private shouldRepositoryBeIgnored(repositoryRoot: string): boolean {
const config = workspace.getConfiguration('git');
const ignoredRepos = config.get<string[]>('ignoredRepositories') || [];
for (const ignoredRepo of ignoredRepos) {
if (path.isAbsolute(ignoredRepo)) {
if (pathEquals(ignoredRepo, repositoryRoot)) {
return true;
}
} else {
for (const folder of workspace.workspaceFolders || []) {
if (pathEquals(path.join(folder.uri.fsPath, ignoredRepo), repositoryRoot)) {
return true;
}
}
}
}
return false;
}
private open(repository: Repository): void {
this.logger.trace(`[Model][open] Repository: ${repository.root}`);
const onDidDisappearRepository = filterEvent(repository.onDidChangeState, state => state === RepositoryState.Disposed);
const disappearListener = onDidDisappearRepository(() => dispose());
const disposeParentListener = repository.sourceControl.onDidDisposeParent(() => dispose());
const changeListener = repository.onDidChangeRepository(uri => this._onDidChangeRepository.fire({ repository, uri }));
const originalResourceChangeListener = repository.onDidChangeOriginalResource(uri => this._onDidChangeOriginalResource.fire({ repository, uri }));
const shouldDetectSubmodules = workspace
.getConfiguration('git', Uri.file(repository.root))
.get<boolean>('detectSubmodules') as boolean;
const submodulesLimit = workspace
.getConfiguration('git', Uri.file(repository.root))
.get<number>('detectSubmodulesLimit') as number;
const shouldDetectWorktrees = workspace
.getConfiguration('git', Uri.file(repository.root))
.get<boolean>('detectWorktrees') as boolean;
const worktreesLimit = workspace
.getConfiguration('git', Uri.file(repository.root))
.get<number>('detectWorktreesLimit') as number;
const checkForSubmodules = () => {
if (!shouldDetectSubmodules) {
this.logger.trace('[Model][open] Automatic detection of git submodules is not enabled.');
return;
}
if (repository.submodules.length > submodulesLimit) {
window.showWarningMessage(l10n.t('The "{0}" repository has {1} submodules which won\'t be opened automatically. You can still open each one individually by opening a file within.', path.basename(repository.root), repository.submodules.length));
statusListener.dispose();
}
repository.submodules
.slice(0, submodulesLimit)
.map(r => path.join(repository.root, r.path))
.forEach(p => {
this.logger.trace(`[Model][open] Opening submodule: '${p}'`);
this.eventuallyScanPossibleGitRepository(p);
});
};
const checkForWorktrees = () => {
if (!shouldDetectWorktrees) {
this.logger.trace('[Model][open] Automatic detection of git worktrees is not enabled.');
return;
}
if (repository.kind === 'worktree') {
this.logger.trace('[Model][open] Automatic detection of git worktrees is not skipped.');
return;
}
if (repository.worktrees.length > worktreesLimit) {
window.showWarningMessage(l10n.t('The "{0}" repository has {1} worktrees which won\'t be opened automatically. You can still open each one individually by opening a file within.', path.basename(repository.root), repository.worktrees.length));
statusListener.dispose();
}
repository.worktrees
.slice(0, worktreesLimit)
.forEach(w => {
this.logger.trace(`[Model][open] Opening worktree: '${w.path}'`);
this.eventuallyScanPossibleGitRepository(w.path);
});
};
const updateMergeChanges = () => {
const mergeChanges: Uri[] = [];
for (const { repository } of this.openRepositories.values()) {
for (const state of repository.mergeGroup.resourceStates) {
mergeChanges.push(state.resourceUri);
}
}
commands.executeCommand('setContext', 'git.mergeChanges', mergeChanges);
};
const statusListener = repository.onDidRunGitStatus(() => {
checkForSubmodules();
checkForWorktrees();
updateMergeChanges();
this.onDidChangeActiveTextEditor();
});
checkForSubmodules();
checkForWorktrees();
this.onDidChangeActiveTextEditor();
const updateOperationInProgressContext = () => {
let operationInProgress = false;
for (const { repository } of this.openRepositories.values()) {
if (repository.operations.shouldDisableCommands()) {
operationInProgress = true;
}
}
commands.executeCommand('setContext', 'operationInProgress', operationInProgress);
};
const operationEvent = anyEvent(repository.onDidRunOperation as Event<any>, repository.onRunOperation as Event<any>);
const operationListener = operationEvent(() => updateOperationInProgressContext());
updateOperationInProgressContext();
const dispose = () => {
disappearListener.dispose();
disposeParentListener.dispose();
changeListener.dispose();
originalResourceChangeListener.dispose();
statusListener.dispose();
operationListener.dispose();
repository.dispose();
this.openRepositories = this.openRepositories.filter(e => e !== openRepository);
this._onDidCloseRepository.fire(repository);
};
const openRepository = { repository, dispose };
this.openRepositories.push(openRepository);
updateMergeChanges();
this._onDidOpenRepository.fire(repository);
}
close(repository: Repository): void {
const openRepository = this.getOpenRepository(repository);
if (!openRepository) {
return;
}
this.logger.info(`[Model][close] Repository: ${repository.root}`);
this._closedRepositoriesManager.addRepository(openRepository.repository.root);
openRepository.dispose();
}
async pickRepository(repositoryFilter?: ('repository' | 'submodule' | 'worktree')[]): Promise<Repository | undefined> {
if (this.openRepositories.length === 0) {
throw new Error(l10n.t('There are no available repositories'));
}
const repositories = this.openRepositories
.filter(r => !repositoryFilter || repositoryFilter.includes(r.repository.kind));
if (repositories.length === 0) {
throw new Error(l10n.t('There are no available repositories matching the filter'));
} else if (repositories.length === 1) {
return repositories[0].repository;
}
const active = window.activeTextEditor;
const picks = repositories.map((e, index) => new RepositoryPick(e.repository, index));
const repository = active && this.getRepository(active.document.fileName);
const index = picks.findIndex(pick => pick.repository === repository);
if (index > -1) {
picks.unshift(...picks.splice(index, 1));
}
const placeHolder = l10n.t('Choose a repository');
const pick = await window.showQuickPick(picks, { placeHolder });
return pick && pick.repository;
}
getRepository(sourceControl: SourceControl): Repository | undefined;
getRepository(resourceGroup: SourceControlResourceGroup): Repository | undefined;
getRepository(path: string): Repository | undefined;
getRepository(resource: Uri): Repository | undefined;
getRepository(hint: any): Repository | undefined {
const liveRepository = this.getOpenRepository(hint);
return liveRepository && liveRepository.repository;
}
private async getRepositoryExact(repoPath: string): Promise<Repository | undefined> {
const openRepository = this.openRepositories
.find(r => pathEquals(r.repository.root, repoPath));
if (openRepository) {
return openRepository.repository;
}
try {
const repoPathRealPath = await fs.promises.realpath(repoPath, { encoding: 'utf8' });
const openRepositoryRealPath = this.openRepositories
.find(r => pathEquals(r.repository.rootRealPath ?? r.repository.root, repoPathRealPath));
return openRepositoryRealPath?.repository;
} catch (err) {
this.logger.warn(`[Model][getRepositoryExact] Failed to get repository realpath for: "${repoPath}". Error:${err}`);
return undefined;
}
}
private getOpenRepository(repository: Repository): OpenRepository | undefined;
private getOpenRepository(sourceControl: SourceControl): OpenRepository | undefined;
private getOpenRepository(resourceGroup: SourceControlResourceGroup): OpenRepository | undefined;
private getOpenRepository(path: string): OpenRepository | undefined;
private getOpenRepository(resource: Uri): OpenRepository | undefined;
private getOpenRepository(hint: any): OpenRepository | undefined {
if (!hint) {
return undefined;
}
if (hint instanceof Repository) {
return this.openRepositories.filter(r => r.repository === hint)[0];
}
if (hint instanceof ApiRepository) {
hint = hint.rootUri;
}
if (typeof hint === 'string') {
hint = Uri.file(hint);
}
if (hint instanceof Uri) {
let resourcePath: string;
if (hint.scheme === 'git') {
resourcePath = fromGitUri(hint).path;
} else {
resourcePath = hint.fsPath;
}
outer:
for (const liveRepository of this.openRepositories.sort((a, b) => b.repository.root.length - a.repository.root.length)) {
if (!isDescendant(liveRepository.repository.root, resourcePath)) {
continue;
}
for (const submodule of liveRepository.repository.submodules) {
const submoduleRoot = path.join(liveRepository.repository.root, submodule.path);
if (isDescendant(submoduleRoot, resourcePath)) {
continue outer;
}
}
return liveRepository;
}
return undefined;
}
for (const liveRepository of this.openRepositories) {
const repository = liveRepository.repository;
if (hint === repository.sourceControl) {
return liveRepository;
}
if (hint === repository.mergeGroup || hint === repository.indexGroup || hint === repository.workingTreeGroup || hint === repository.untrackedGroup) {
return liveRepository;
}
}
return undefined;
}
getRepositoryForSubmodule(submoduleUri: Uri): Repository | undefined {
for (const repository of this.repositories) {
for (const submodule of repository.submodules) {
const submodulePath = path.join(repository.root, submodule.path);
if (submodulePath === submoduleUri.fsPath) {
return repository;
}
}
}
return undefined;
}
registerRemoteSourcePublisher(publisher: RemoteSourcePublisher): Disposable {
this.remoteSourcePublishers.add(publisher);
this._onDidAddRemoteSourcePublisher.fire(publisher);
return toDisposable(() => {
this.remoteSourcePublishers.delete(publisher);
this._onDidRemoveRemoteSourcePublisher.fire(publisher);
});
}
getRemoteSourcePublishers(): RemoteSourcePublisher[] {
return [...this.remoteSourcePublishers.values()];
}
registerBranchProtectionProvider(root: Uri, provider: BranchProtectionProvider): Disposable {
const providerDisposables: Disposable[] = [];
this.branchProtectionProviders.set(root.toString(), (this.branchProtectionProviders.get(root.toString()) ?? 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.toString());
if (providers && providers.has(provider)) {
providers.delete(provider);
this.branchProtectionProviders.set(root.toString(), providers);
this._onDidChangeBranchProtectionProviders.fire(root);
}
dispose(providerDisposables);
});
}
getBranchProtectionProviders(root: Uri): BranchProtectionProvider[] {
return [...(this.branchProtectionProviders.get(root.toString()) ?? new Set()).values()];
}
registerPostCommitCommandsProvider(provider: PostCommitCommandsProvider): Disposable {
this.postCommitCommandsProviders.add(provider);
this._onDidChangePostCommitCommandsProviders.fire();
return toDisposable(() => {
this.postCommitCommandsProviders.delete(provider);
this._onDidChangePostCommitCommandsProviders.fire();
});
}
getPostCommitCommandsProviders(): PostCommitCommandsProvider[] {
return [...this.postCommitCommandsProviders.values()];
}
registerCredentialsProvider(provider: CredentialsProvider): Disposable {
return this.askpass.registerCredentialsProvider(provider);
}
registerPushErrorHandler(handler: PushErrorHandler): Disposable {
this.pushErrorHandlers.add(handler);
return toDisposable(() => this.pushErrorHandlers.delete(handler));
}
getPushErrorHandlers(): PushErrorHandler[] {
return [...this.pushErrorHandlers];
}
registerSourceControlHistoryItemDetailsProvider(provider: SourceControlHistoryItemDetailsProvider): Disposable {
this.historyItemDetailsProviders.add(provider);
return toDisposable(() => this.historyItemDetailsProviders.delete(provider));
}
getSourceControlHistoryItemDetailsProviders(): SourceControlHistoryItemDetailsProvider[] {
return [...this.historyItemDetailsProviders];
}
getUnsafeRepositoryPath(repository: string): string | undefined {
return this._unsafeRepositoriesManager.getRepositoryPath(repository);
}
deleteUnsafeRepository(repository: string): boolean {
return this._unsafeRepositoriesManager.deleteRepository(repository);
}
private async isRepositoryOutsideWorkspace(repositoryPath: string): Promise<boolean> {
const workspaceFolders = (workspace.workspaceFolders || [])
.filter(folder => folder.uri.scheme === 'file');
if (workspaceFolders.length === 0) {
return true;
}
const workspaceFolderPaths = new Set<string | undefined>([
...workspaceFolders.map(folder => folder.uri.fsPath),
...await Promise.all(workspaceFolders.map(folder => this.getWorkspaceFolderRealPath(folder)))
]);
return !Array.from(workspaceFolderPaths).some(folder => folder && (pathEquals(folder, repositoryPath) || isDescendant(folder, repositoryPath)));
}
private async getWorkspaceFolderRealPath(workspaceFolder: WorkspaceFolder): Promise<string | undefined> {
let result = this._workspaceFolders.get(workspaceFolder.uri.fsPath);
if (!result) {
try {
result = await fs.promises.realpath(workspaceFolder.uri.fsPath, { encoding: 'utf8' });
this._workspaceFolders.set(workspaceFolder.uri.fsPath, result);
} catch (err) {
this.logger.trace(`[Model][getWorkspaceFolderRealPath] Failed to resolve workspace folder "${workspaceFolder.uri.fsPath}". Error:${err}`);
}
}
return result;
}
private async showParentRepositoryNotification(): Promise<void> {
const message = this.parentRepositories.length === 1 ?
l10n.t('A git repository was found in the parent folders of the workspace or the open file(s). Would you like to open the repository?') :
l10n.t('Git repositories were found in the parent folders of the workspace or the open file(s). Would you like to open the repositories?');
const yes = l10n.t('Yes');
const always = l10n.t('Always');
const never = l10n.t('Never');
const choice = await window.showInformationMessage(message, yes, always, never);
if (choice === yes) {
commands.executeCommand('git.openRepositoriesInParentFolders');
} else if (choice === always || choice === never) {
const config = workspace.getConfiguration('git');
await config.update('openRepositoryInParentFolders', choice === always ? 'always' : 'never', true);
if (choice === always) {
for (const parentRepository of this.parentRepositories) {
await this.openParentRepository(parentRepository);
}
}
}
}
private async showUnsafeRepositoryNotification(): Promise<void> {
if (this.repositories.length === 0) {
return;
}
const message = this.unsafeRepositories.length === 1 ?
l10n.t('The git repository in the current folder is potentially unsafe as the folder is owned by someone other than the current user.') :
l10n.t('The git repositories in the current folder are potentially unsafe as the folders are owned by someone other than the current user.');
const manageUnsafeRepositories = l10n.t('Manage Unsafe Repositories');
const learnMore = l10n.t('Learn More');
const choice = await window.showErrorMessage(message, manageUnsafeRepositories, learnMore);
if (choice === manageUnsafeRepositories) {
commands.executeCommand('git.manageUnsafeRepositories');
} else if (choice === learnMore) {
commands.executeCommand('vscode.open', Uri.parse('https://aka.ms/vscode-git-unsafe-repository'));
}
}
disposeRepository(repository: Repository): void {
const openRepository = this.getOpenRepository(repository);
if (!openRepository) {
return;
}
this.logger.info(`[Model][disposeRepository] Repository: ${repository.root}`);
openRepository.dispose();
}
dispose(): void {
const openRepositories = [...this.openRepositories];
openRepositories.forEach(r => r.dispose());
this.openRepositories = [];
this.possibleGitRepositoryPaths.clear();
this.disposables = dispose(this.disposables);
}
}