Skip to content

Commit 3a52e79

Browse files
authored
Git - handle unsafe repositories (microsoft#167248)
1 parent 227e4ca commit 3a52e79

File tree

6 files changed

+211
-12
lines changed

6 files changed

+211
-12
lines changed

extensions/git/package.json

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,16 @@
623623
"title": "%command.git.runGitMergeDiff3%",
624624
"category": "Git",
625625
"enablement": "isMergeEditor"
626+
},
627+
{
628+
"command": "git.addSafeDirectoryAndOpenRepository",
629+
"title": "%command.addSafeDirectoryAndOpenRepository%",
630+
"category": "Git"
631+
},
632+
{
633+
"command": "git.api.getUnsafeRepositories",
634+
"title": "%command.api.getUnsafeRepositories%",
635+
"category": "Git"
626636
}
627637
],
628638
"keybindings": [
@@ -1047,9 +1057,17 @@
10471057
"command": "git.api.getRemoteSources",
10481058
"when": "false"
10491059
},
1060+
{
1061+
"command": "git.api.getUnsafeRepositories",
1062+
"when": "false"
1063+
},
10501064
{
10511065
"command": "git.openMergeEditor",
10521066
"when": "false"
1067+
},
1068+
{
1069+
"command": "git.addSafeDirectoryAndOpenRepository",
1070+
"when": "config.git.enabled && !git.missing && git.unsafeRepositoryCount != 0"
10531071
}
10541072
],
10551073
"scm/title": [
@@ -2697,30 +2715,48 @@
26972715
{
26982716
"view": "scm",
26992717
"contents": "%view.workbench.scm.empty%",
2700-
"when": "config.git.enabled && !git.missing && workbenchState == empty",
2718+
"when": "config.git.enabled && !git.missing && workbenchState == empty && git.unsafeRepositoryCount == 0",
27012719
"enablement": "git.state == initialized",
27022720
"group": "2_open@1"
27032721
},
27042722
{
27052723
"view": "scm",
2706-
"contents": "%view.workbench.scm.folder%",
2707-
"when": "config.git.enabled && !git.missing && workbenchState == folder && remoteName != 'codespaces'",
2724+
"contents": "%view.workbench.scm.emptyWorkspace%",
2725+
"when": "config.git.enabled && !git.missing && workbenchState == workspace && workspaceFolderCount == 0 && git.unsafeRepositoryCount == 0",
27082726
"enablement": "git.state == initialized",
2727+
"group": "2_open@1"
2728+
},
2729+
{
2730+
"view": "scm",
2731+
"contents": "%view.workbench.scm.scanFolderForRepositories%",
2732+
"when": "config.git.enabled && !git.missing && workbenchState == folder && workspaceFolderCount != 0 && git.state != initialized"
2733+
},
2734+
{
2735+
"view": "scm",
2736+
"contents": "%view.workbench.scm.scanWorkspaceForRepositories%",
2737+
"when": "config.git.enabled && !git.missing && workbenchState == workspace && workspaceFolderCount != 0 && git.state != initialized"
2738+
},
2739+
{
2740+
"view": "scm",
2741+
"contents": "%view.workbench.scm.folder%",
2742+
"when": "config.git.enabled && !git.missing && git.state == initialized && workbenchState == folder && scmRepositoryCount == 0 && git.unsafeRepositoryCount == 0 && remoteName != 'codespaces'",
27092743
"group": "5_scm@1"
27102744
},
27112745
{
27122746
"view": "scm",
27132747
"contents": "%view.workbench.scm.workspace%",
2714-
"when": "config.git.enabled && !git.missing && workbenchState == workspace && workspaceFolderCount != 0 && remoteName != 'codespaces'",
2715-
"enablement": "git.state == initialized",
2748+
"when": "config.git.enabled && !git.missing && git.state == initialized && workbenchState == workspace && workspaceFolderCount != 0 && scmRepositoryCount == 0 && git.unsafeRepositoryCount == 0 && remoteName != 'codespaces'",
27162749
"group": "5_scm@1"
27172750
},
27182751
{
27192752
"view": "scm",
2720-
"contents": "%view.workbench.scm.emptyWorkspace%",
2721-
"when": "config.git.enabled && !git.missing && workbenchState == workspace && workspaceFolderCount == 0",
2722-
"enablement": "git.state == initialized",
2723-
"group": "2_open@1"
2753+
"contents": "%view.workbench.scm.unsafeRepository%",
2754+
"when": "config.git.enabled && !git.missing && git.state == initialized && git.unsafeRepositoryCount == 1"
2755+
},
2756+
{
2757+
"view": "scm",
2758+
"contents": "%view.workbench.scm.unsafeRepositories%",
2759+
"when": "config.git.enabled && !git.missing && git.state == initialized && git.unsafeRepositoryCount > 1"
27242760
},
27252761
{
27262762
"view": "explorer",

extensions/git/package.nls.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,11 @@
9999
"command.timelineCopyCommitMessage": "Copy Commit Message",
100100
"command.timelineSelectForCompare": "Select for Compare",
101101
"command.timelineCompareWithSelected": "Compare with Selected",
102+
"command.addSafeDirectoryAndOpenRepository": "Mark Repository as Safe and Open",
102103
"command.api.getRepositories": "Get Repositories",
103104
"command.api.getRepositoryState": "Get Repository State",
104105
"command.api.getRemoteSources": "Get Remote Sources",
106+
"command.api.getUnsafeRepositories": "Get Unsafe Repositories",
105107
"command.git.acceptMerge": "Complete Merge",
106108
"command.git.openMergeEditor": "Resolve in Merge Editor",
107109
"command.git.runGitMerge": "Compute Conflicts With Git",
@@ -322,6 +324,30 @@
322324
"Please make sure there is no space between the right bracket and left parenthesis: ]( this is an internal syntax for links"
323325
]
324326
},
327+
"view.workbench.scm.scanFolderForRepositories": {
328+
"message": "Scanning folder for git repositories..."
329+
},
330+
"view.workbench.scm.scanWorkspaceForRepositories": {
331+
"message": "Scanning workspace for git repositories..."
332+
},
333+
"view.workbench.scm.unsafeRepository": {
334+
"message": "The git repository in the following folder has been detected as potentially unsafe as the folder is owned by someone else other than the current user: ${command:git.api.getUnsafeRepositories}.\nDo you want to open the repository?\n[Open Repository](command:git.addSafeDirectoryAndOpenRepository)\n[Learn More](https://aka.ms/vscode-scm)",
335+
"comment": [
336+
"{Locked='](command:git.api.getUnsafeRepositories'}",
337+
"{Locked='](command:git.addSafeDirectoryAndOpenRepository'}",
338+
"Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for VS Code",
339+
"Please make sure there is no space between the right bracket and left parenthesis: ]( this is an internal syntax for links"
340+
]
341+
},
342+
"view.workbench.scm.unsafeRepositories": {
343+
"message": "The git repositories in the following folders have been detected as potentially unsafe as the folder is owned by someone else other than the current user: ${command:git.api.getUnsafeRepositories}.\nDo you want to open the repositories?\n[Open Repositories](command:git.addSafeDirectoryAndOpenRepository)\n[Learn More](https://aka.ms/vscode-scm)",
344+
"comment": [
345+
"{Locked='](command:git.api.getUnsafeRepositories'}",
346+
"{Locked='](command:git.addSafeDirectoryAndOpenRepository'}",
347+
"Do not translate the 'command:*' part inside of the '(..)'. It is an internal command syntax for VS Code",
348+
"Please make sure there is no space between the right bracket and left parenthesis: ]( this is an internal syntax for links"
349+
]
350+
},
325351
"view.workbench.cloneRepository": {
326352
"message": "You can clone a repository locally.\n[Clone Repository](command:git.clone 'Clone a repository once the git extension has activated')",
327353
"comment": [

extensions/git/src/commands.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3182,6 +3182,17 @@ export class CommandCenter {
31823182
repository.closeDiffEditors(undefined, undefined, true);
31833183
}
31843184

3185+
@command('git.api.getUnsafeRepositories')
3186+
getUnsafeRepositories(): string {
3187+
const repositories = Array.from(this.model.unsafeRepositories.values());
3188+
return repositories.sort().map(m => `"${m}"`).join(', ');
3189+
}
3190+
3191+
@command('git.addSafeDirectoryAndOpenRepository')
3192+
async addSafeDirectoryAndOpenRepository(): Promise<void> {
3193+
await this.model.addSafeDirectoryAndOpenRepository();
3194+
}
3195+
31853196
private createCommand(id: string, key: string, method: Function, options: ScmCommandOptions): (...args: any[]) => any {
31863197
const result = (...args: any[]) => {
31873198
let result: Promise<any>;

extensions/git/src/git.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -684,6 +684,11 @@ export class Git {
684684
}
685685
}
686686
}
687+
688+
async addSafeDirectory(repositoryPath: string): Promise<void> {
689+
await this.exec(repositoryPath, ['config', '--global', '--add', 'safe.directory', repositoryPath]);
690+
return;
691+
}
687692
}
688693

689694
export interface Commit {

extensions/git/src/model.ts

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6-
import { workspace, WorkspaceFoldersChangeEvent, Uri, window, Event, EventEmitter, QuickPickItem, Disposable, SourceControl, SourceControlResourceGroup, TextEditor, Memento, commands, LogOutputChannel, l10n, ProgressLocation } from 'vscode';
6+
import { workspace, WorkspaceFoldersChangeEvent, Uri, window, Event, EventEmitter, QuickPickItem, Disposable, SourceControl, SourceControlResourceGroup, TextEditor, Memento, commands, LogOutputChannel, l10n, ProgressLocation, QuickPickItemKind } from 'vscode';
77
import TelemetryReporter from '@vscode/extension-telemetry';
88
import { Operation, Repository, RepositoryState } from './repository';
99
import { memoize, sequentialize, debounce } from './decorators';
@@ -33,6 +33,31 @@ class RepositoryPick implements QuickPickItem {
3333
constructor(public readonly repository: Repository, public readonly index: number) { }
3434
}
3535

36+
class UnsafeRepositorySet extends Set<string> {
37+
constructor() {
38+
super();
39+
this.updateContextKey();
40+
}
41+
42+
override add(value: string): this {
43+
const result = super.add(value);
44+
this.updateContextKey();
45+
46+
return result;
47+
}
48+
49+
override delete(value: string): boolean {
50+
const result = super.delete(value);
51+
this.updateContextKey();
52+
53+
return result;
54+
}
55+
56+
private updateContextKey(): void {
57+
commands.executeCommand('setContext', 'git.unsafeRepositoryCount', this.size);
58+
}
59+
}
60+
3661
export interface ModelChangeEvent {
3762
repository: Repository;
3863
uri: Uri;
@@ -110,6 +135,11 @@ export class Model implements IRemoteSourcePublisherRegistry, IPostCommitCommand
110135
private showRepoOnHomeDriveRootWarning = true;
111136
private pushErrorHandlers = new Set<PushErrorHandler>();
112137

138+
private _unsafeRepositories = new UnsafeRepositorySet();
139+
get unsafeRepositories(): Set<string> {
140+
return this._unsafeRepositories;
141+
}
142+
113143
private disposables: Disposable[] = [];
114144

115145
constructor(readonly git: Git, private readonly askpass: Askpass, private globalState: Memento, private logger: LogOutputChannel, private telemetryReporter: TelemetryReporter) {
@@ -145,6 +175,11 @@ export class Model implements IRemoteSourcePublisherRegistry, IPostCommitCommand
145175
await initialScanFn();
146176
}
147177

178+
// Show unsafe repositories notification if we cannot use a welcome view
179+
if (this.repositories.length > 0 && this._unsafeRepositories.size > 0) {
180+
await this.showUnsafeRepositoryNotification();
181+
}
182+
148183
/* __GDPR__
149184
"git.repositoryInitialScan" : {
150185
"owner": "lszomoru",
@@ -394,6 +429,23 @@ export class Model implements IRemoteSourcePublisherRegistry, IPostCommitCommand
394429
this.open(repository);
395430
repository.status(); // do not await this, we want SCM to know about the repo asap
396431
} catch (ex) {
432+
// Handle unsafe repository
433+
const match = /^fatal: detected dubious ownership in repository at \'([^']+)\'$/m.exec(ex.stderr);
434+
if (match && match.length === 2) {
435+
const unsafeRepositoryPath = match[1];
436+
this.logger.trace(`Unsafe repository: ${unsafeRepositoryPath}`);
437+
438+
// If the unsafe repository is opened after the initial repository scan, and we cannot use the welcome view
439+
// as there is already at least one opened repository, we will be showing a notification for the repository.
440+
if (this._state === 'initialized' && this.openRepositories.length > 0 && !this._unsafeRepositories.has(unsafeRepositoryPath)) {
441+
this.showUnsafeRepositoryNotification(unsafeRepositoryPath);
442+
}
443+
444+
this._unsafeRepositories.add(unsafeRepositoryPath);
445+
446+
return;
447+
}
448+
397449
// noop
398450
this.logger.trace(`Opening repository for path='${repoPath}' failed; ex=${ex}`);
399451
}
@@ -675,6 +727,75 @@ export class Model implements IRemoteSourcePublisherRegistry, IPostCommitCommand
675727
return [...this.pushErrorHandlers];
676728
}
677729

730+
async addSafeDirectoryAndOpenRepository() {
731+
const unsafeRepositories: string[] = [];
732+
733+
if (this._unsafeRepositories.size === 1) {
734+
// One unsafe repository
735+
unsafeRepositories.push(this._unsafeRepositories.values().next().value);
736+
} else {
737+
// Multiple unsafe repositories
738+
const allRepositoriesLabel = l10n.t('All Repositories');
739+
const allRepositoriesQuickPickItem: QuickPickItem = { label: allRepositoriesLabel };
740+
const repositoriesQuickPickItems: QuickPickItem[] = Array.from(this._unsafeRepositories.values()).sort().map(r => ({ label: `$(repo) ${r}` }));
741+
742+
const quickpick = window.createQuickPick();
743+
quickpick.title = l10n.t('Mark Repository as Safe and Open');
744+
quickpick.placeholder = l10n.t('Pick a repository to mark as safe and open');
745+
quickpick.items = [...repositoriesQuickPickItems, { label: '', kind: QuickPickItemKind.Separator }, allRepositoriesQuickPickItem];
746+
747+
quickpick.show();
748+
const repositoryItem = await new Promise<string | undefined>(
749+
resolve => {
750+
quickpick.onDidAccept(() => resolve(quickpick.activeItems[0].label));
751+
quickpick.onDidHide(() => resolve(undefined));
752+
});
753+
quickpick.hide();
754+
755+
if (!repositoryItem) {
756+
return;
757+
}
758+
759+
if (repositoryItem === allRepositoriesLabel) {
760+
// All Repositories
761+
unsafeRepositories.push(...this._unsafeRepositories.values());
762+
} else {
763+
// One Repository
764+
unsafeRepositories.push(repositoryItem);
765+
}
766+
}
767+
768+
for (const unsafeRepository of unsafeRepositories) {
769+
// Mark as Safe
770+
await this.git.addSafeDirectory(unsafeRepository);
771+
772+
// Open Repository
773+
await this.openRepository(unsafeRepository);
774+
this._unsafeRepositories.delete(unsafeRepository);
775+
}
776+
}
777+
778+
private async showUnsafeRepositoryNotification(path?: string): Promise<void> {
779+
const unsafeRepositoryPaths: string[] = path ? [path] : Array.from(this._unsafeRepositories.values());
780+
const unsafeRepositoryPathLabels = unsafeRepositoryPaths.sort().map(m => `"${m}"`).join(', ');
781+
782+
const message = unsafeRepositoryPaths.length === 1 ?
783+
l10n.t('The git repository in the following folder has been detected as potentially unsafe as the folder is owned by someone else other than the current user: {0}. Do you want to open the repository?', unsafeRepositoryPathLabels) :
784+
l10n.t('The git repositories in the following folders have been detected as potentially unsafe as the folder is owned by someone else other than the current user: {0}. Do you want to open the repositories?', unsafeRepositoryPathLabels);
785+
786+
const openRepository = unsafeRepositoryPaths.length === 1 ? l10n.t('Open Repository') : l10n.t('Open Repositories');
787+
const learnMore = l10n.t('Learn More');
788+
789+
const choice = await window.showErrorMessage(message, openRepository, learnMore);
790+
if (choice === openRepository) {
791+
// Open Repository
792+
await this.addSafeDirectoryAndOpenRepository();
793+
} else if (choice === learnMore) {
794+
// Learn More
795+
commands.executeCommand('vscode.open', Uri.parse('https://aka.ms/vscode-scm'));
796+
}
797+
}
798+
678799
dispose(): void {
679800
const openRepositories = [...this.openRepositories];
680801
openRepositories.forEach(r => r.dispose());

extensions/github/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,12 +118,12 @@
118118
{
119119
"view": "scm",
120120
"contents": "%welcome.publishFolder%",
121-
"when": "config.git.enabled && git.state == initialized && workbenchState == folder"
121+
"when": "config.git.enabled && git.state == initialized && workbenchState == folder && git.unsafeRepositoryCount == 0"
122122
},
123123
{
124124
"view": "scm",
125125
"contents": "%welcome.publishWorkspaceFolder%",
126-
"when": "config.git.enabled && git.state == initialized && workbenchState == workspace && workspaceFolderCount != 0"
126+
"when": "config.git.enabled && git.state == initialized && workbenchState == workspace && workspaceFolderCount != 0 && git.unsafeRepositoryCount == 0"
127127
}
128128
],
129129
"markdown.previewStyles": [

0 commit comments

Comments
 (0)